diff --git a/.github/workflows/cargo-cache-seed.yml b/.github/workflows/cargo-cache-seed.yml new file mode 100644 index 000000000..55fcc4a49 --- /dev/null +++ b/.github/workflows/cargo-cache-seed.yml @@ -0,0 +1,93 @@ +# `ci.yml` runs on `pull_request` only, and GitHub scopes a cache entry to the +# ref that wrote it — a PR writes under `refs/pull/N/merge`, invisible to every +# other PR, so each PR's first cargo run is cold. A run can also read entries +# scoped to the default branch, which is what this workflow writes. +# +# Two entries, not one per cargo job: the per-job keys stay each PR's own save +# key and gain a trailing `main-cargo-` / `main-wasm-cargo-` restore-key that +# reaches these. The keys hash `Cargo.lock` and the toolchain — the two inputs +# that invalidate a dependency tree — so a merge that changes neither hits its +# own key and writes nothing. The repo's cache budget is already tight, and +# these entries have to earn their bytes against the PR-scoped ones LRU evicts. +name: Cargo Cache Seed + +on: + push: + branches: [main] + paths: + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/cargo-cache-seed.yml' + # An entry LRU evicted between lockfile changes has no push to recreate it. + schedule: + - cron: '0 5 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: cargo-cache-seed + cancel-in-progress: true + +jobs: + # Serves every job keyed `linux-cargo-`, `core-kats-cargo-` and + # `client-browser-cargo-`: one host `target` tree and one registry. + native: + name: Seed the native Linux cargo cache + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: main-cargo-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: main-cargo- + + # Compiled, not run: what a PR restores this for is the dependency tree. + # The desktop shell stays out — Tauri needs webkit2gtk system deps, and + # its own jobs cache under their own keys. + - name: Build the test targets the PR cargo jobs compile + run: cargo test --workspace --exclude cipherbox-desktop --no-run + + # Serves the `wasm-engine-cargo-` and `web-e2e-cargo-` jobs, whose path set is + # the host build tree plus the browser target rather than the whole of `target`. + wasm: + name: Seed the wasm cargo cache + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target/release + target/wasm32-unknown-unknown + key: main-wasm-cargo-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: main-wasm-cargo- + + - uses: ./.github/actions/wasm-bindgen-cli + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: 'package.json' + + - name: Build the engine WASM + run: pnpm --filter @cipherbox/web run build:wasm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1252ac91..595f40df0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,7 +220,9 @@ jobs: target/release target/wasm32-unknown-unknown key: wasm-engine-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: wasm-engine-cargo- + restore-keys: | + wasm-engine-cargo- + main-wasm-cargo- - uses: ./.github/actions/wasm-bindgen-cli @@ -473,7 +475,9 @@ jobs: ~/.cargo/git target key: linux-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: linux-cargo- + restore-keys: | + linux-cargo- + main-cargo- - name: Cargo fmt run: cargo fmt --all --check @@ -508,7 +512,9 @@ jobs: ~/.cargo/git target key: linux-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: linux-cargo- + restore-keys: | + linux-cargo- + main-cargo- - name: Engine KAT vectors fresh (committed generator is the only writer) run: | @@ -543,7 +549,9 @@ jobs: ~/.cargo/git target key: linux-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: linux-cargo- + restore-keys: | + linux-cargo- + main-cargo- - name: FUSE operation-core suite run: cargo test -p cipherbox-fuse @@ -573,7 +581,9 @@ jobs: ~/.cargo/git target key: core-kats-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: core-kats-cargo- + restore-keys: | + core-kats-cargo- + main-cargo- # wasm-bindgen-test-runner drives the wasm32-unknown-unknown legs in Node. - uses: ./.github/actions/wasm-bindgen-cli @@ -662,7 +672,9 @@ jobs: ~/.cargo/git target key: client-browser-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: client-browser-cargo- + restore-keys: | + client-browser-cargo- + main-cargo- # wasm-bindgen-cli drives the conformance module build. - uses: ./.github/actions/wasm-bindgen-cli @@ -824,7 +836,9 @@ jobs: ~/.cargo/git target key: linux-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: linux-cargo- + restore-keys: | + linux-cargo- + main-cargo- - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index a2582dd2b..010f524d2 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -49,6 +49,20 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + # The write path's block store. Without it `KuboPinStore` refuses every + # hosted upload with a 503, which the drain charges as a spent attempt — + # so a write spec dead-letters rather than merely failing to publish. + ipfs: + image: ipfs/kubo:v0.42.0 + ports: + - 5001:5001 + - 8080:8080 + options: >- + --health-cmd "ipfs id" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + --health-start-period 30s env: DB_HOST: localhost @@ -57,6 +71,7 @@ jobs: DB_PASSWORD: postgres DB_DATABASE: cipherbox_test JWT_SECRET: web-e2e-jwt-secret + KUBO_API_URL: http://localhost:5001 # Every test cold-starts its own account from one runner IP, so the # per-IP auth bucket would otherwise cap how many specs can exist. THROTTLE_AUTH_LIMIT: '200' @@ -73,6 +88,11 @@ jobs: # The hermetic record store, in place of the public default — a merge gate # must not resolve vault pointers over the internet. VITE_ROUTING_ENDPOINTS: http://localhost:3001 + # The same stack's gateway, so a read pulls its blocks back from where the + # upload pinned them rather than from the tab that wrote them. By address, + # not by name: Kubo serves `localhost` as a *subdomain* gateway and 301s + # every path request to `.ipfs.localhost`, which resolves nowhere. + VITE_READ_ACCELERATOR_URL: http://127.0.0.1:8080 steps: - name: Checkout code @@ -97,7 +117,9 @@ jobs: target/release target/wasm32-unknown-unknown key: web-e2e-cargo-${{ hashFiles('Cargo.lock') }} - restore-keys: web-e2e-cargo- + restore-keys: | + web-e2e-cargo- + main-wasm-cargo- - uses: ./.github/actions/wasm-bindgen-cli if: inputs.wasm-artifact == '' diff --git a/apps/web/src/engine/introspection.test.ts b/apps/web/src/engine/introspection.test.ts index 647256629..41a43c2f0 100644 --- a/apps/web/src/engine/introspection.test.ts +++ b/apps/web/src/engine/introspection.test.ts @@ -74,6 +74,28 @@ describe('installIntrospection', () => { expect((await pending)?.settled).toBe(false); }); + it('reads a node named in hex and hands its plaintext back the same way', async () => { + const engine = fakeEngine(); + const download = vi.fn().mockResolvedValue(Uint8Array.of(0xde, 0xad, 0xbe, 0xef).buffer); + (engine.client.facade as unknown as { download: unknown }).download = download; + installIntrospection(engine.client); + + const plaintext = await window.__CIPHERBOX_ENGINE__?.download('0102030405060708'); + + expect(download).toHaveBeenCalledWith(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8)); + expect(plaintext).toBe('deadbeef'); + }); + + it.each(['010', 'zz'])('refuses %s as a node id, before the engine is asked', async (bad) => { + const engine = fakeEngine(); + const download = vi.fn(); + (engine.client.facade as unknown as { download: unknown }).download = download; + installIntrospection(engine.client); + + await expect(window.__CIPHERBOX_ENGINE__?.download(bad)).rejects.toThrow(TypeError); + expect(download).not.toHaveBeenCalled(); + }); + it('records the event stream in emission order', () => { const engine = fakeEngine(); installIntrospection(engine.client); diff --git a/apps/web/src/engine/introspection.ts b/apps/web/src/engine/introspection.ts index 81b18e29b..78bf082e3 100644 --- a/apps/web/src/engine/introspection.ts +++ b/apps/web/src/engine/introspection.ts @@ -8,7 +8,7 @@ * hook from is the very one under test. */ -import { toHex } from '@cipherbox/client'; +import { fromHex, toHex } from '@cipherbox/client'; import { handOffLoginSecret } from '@cipherbox/login'; import type { EngineClient, EventDescriptor, SnapshotDescriptor } from '@cipherbox/client'; import { authStore } from '../stores/auth.store'; @@ -39,6 +39,8 @@ export interface EngineIntrospection { signIn(loginSecretHex: string): Promise; /** The engine's view of the vault root. */ snapshot(): Promise; + /** One node's plaintext as the engine reads it back, hex like every other tap. */ + download(nodeHex: string): Promise; /** Every engine event this tab has seen, in emission order. */ events(): Plain[]; } @@ -72,6 +74,9 @@ export function installIntrospection(client: EngineClient): EngineClient { const view = await client.facade.snapshot(null); return { view: plain(view) as Plain, settled: settled(view) }; }, + async download(nodeHex) { + return toHex(new Uint8Array(await client.facade.download(fromHex(nodeHex)))); + }, events: () => seen, }; return client; diff --git a/crates/engine/src/content/read.rs b/crates/engine/src/content/read.rs index 3df1c0d4b..e0f468930 100644 --- a/crates/engine/src/content/read.rs +++ b/crates/engine/src/content/read.rs @@ -149,6 +149,17 @@ impl GatewaySource { bearer: SessionBearer::default(), } } + + /// The only way a source is handed `bearer`: a URL that cannot keep it + /// yields a public source instead ([`carries_credentials_safely`]). + pub fn accelerator(base_url: impl Into, bearer: SessionBearer) -> Self { + let base_url = base_url.into(); + if carries_credentials_safely(&base_url) { + Self { base_url, bearer } + } else { + Self::public(base_url) + } + } } /// The ordered read source set: the token-authed accelerator is tried first as @@ -168,24 +179,21 @@ impl Gateway { } } -/// Whether `base_url` may be handed a credential: TLS, or a loopback host for -/// local development (`apps/web/.env.example` ships a `http://localhost` Kubo). -/// The accelerator URL is host configuration, so a stale or mistyped one must -/// cost the member their acceleration rather than their session token. +/// Whether `base_url` may be handed a credential: TLS, and no credentials of +/// its own. The token authorizes the whole API, so it must not ride a cleartext +/// hop, and a `user:pass@host` authority would send Basic auth beside it. +/// +/// The prefix is deliberate rather than a URL parse: a parser accepts `HTTPS://` +/// as TLS, and every divergence between the two must fall on the denying side. fn carries_credentials_safely(base_url: &str) -> bool { - if base_url.starts_with("https://") { - return true; - } - let Some(rest) = base_url.strip_prefix("http://") else { + let Some(rest) = base_url.strip_prefix("https://") else { return false; }; let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); - // Split a `:port` suffix without splitting an IPv6 literal's own colons. - let host = match authority.rsplit_once(':') { - Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, - _ => authority, - }; - matches!(host, "localhost" | "127.0.0.1" | "[::1]") + // `user:pass@host` would ride as Basic auth beside the bearer, and an empty + // authority is the same URL a slash short — a parser reads its userinfo as + // the path, so the host the token reaches is not the configured one. + !authority.is_empty() && !authority.contains('@') } /// The content-gateway configuration handed to [`Engine::new`](crate::Engine), @@ -218,16 +226,9 @@ impl GatewayConfig { /// a leg denied it still serves reads, just unauthenticated. pub fn into_gateway(self, accelerator_bearer: SessionBearer) -> Gateway { Gateway { - accelerator: self.accelerator.map(|base_url| { - if carries_credentials_safely(&base_url) { - GatewaySource { - base_url, - bearer: accelerator_bearer, - } - } else { - GatewaySource::public(base_url) - } - }), + accelerator: self + .accelerator + .map(|base_url| GatewaySource::accelerator(base_url, accelerator_bearer)), public_fallbacks: self .public_fallbacks .into_iter() @@ -658,6 +659,44 @@ mod tests { ); } + /// The other half of the denial, on the wire rather than in the config: a + /// leg refused the bearer is still the first source consulted, and it sends + /// no `Authorization` — which is what a stock gateway's CORS allow-list and + /// a token-free local Kubo both need. + #[test] + fn a_plain_http_accelerator_is_consulted_and_carries_no_authorization() { + let leaf = one_leaf(); + let http = ScriptedHttp::default(); + http.enqueue_response(raw_response(leaf.sealed.clone())); + + let gateway = GatewayConfig { + accelerator: Some("http://127.0.0.1:8080".into()), + public_fallbacks: Vec::new(), + } + .into_gateway(SessionBearer::holding("member-token")); + + let out = block_on(read_block( + &gateway, + &http, + &cid_str(), + &leaf.cid, + ContentPlane::Leaf, + )) + .unwrap(); + assert_eq!(out, leaf.sealed); + + let requests = http.requests(); + assert_eq!(requests.len(), 1); + assert!(requests[0].url.starts_with("http://127.0.0.1:8080/ipfs/")); + assert!( + !requests[0] + .headers + .iter() + .any(|(name, _)| name == AUTHORIZATION), + "a denied accelerator carries no bearer token" + ); + } + /// A source whose bearer cannot be a header value is skipped, never /// contacted without it — and rotation still reaches a healthy source. #[test] @@ -898,17 +937,30 @@ mod tests { ); } - /// The accelerator URL is host configuration and the token it would be - /// handed authorizes the whole API, so a stale or mistyped value must cost - /// the acceleration, not the session. The leg still serves reads. + /// Anything but a bare TLS URL is denied the token. The leg still serves + /// reads, so a denial costs the member their acceleration at most. #[test] - fn an_accelerator_that_cannot_keep_a_credential_is_never_handed_one() { + fn an_accelerator_that_is_not_plain_tls_is_never_handed_a_credential() { for base_url in [ "http://gw.cipherbox.test", + "http://localhost:8080", + "http://localhost", + "http://127.0.0.1:8080/", + "http://[::1]:8080", "http://localhost.evil.test:8080", "http://127.0.0.1.evil.test", "ftp://gw.cipherbox.test", "//gw.cipherbox.test", + // A URL parse would read the first three as TLS, and the rest carry + // an authority the token must not reach: `user:pass` as Basic auth + // beside the bearer, or — a slash short — none at all. + "HTTPS://gw.cipherbox.test", + " https://gw.cipherbox.test", + "https:/gw.cipherbox.test", + "https://member:secret@gw.cipherbox.test", + "https:///member:secret@gw.cipherbox.test", + "https:////member:secret@gw.cipherbox.test", + "https://", ] { let gateway = GatewayConfig { accelerator: Some(base_url.to_owned()), @@ -921,16 +973,15 @@ mod tests { } } - /// TLS anywhere, and plain HTTP only on loopback — the local Kubo - /// `apps/web/.env.example` ships must stay usable. + /// TLS is the whole rule: a leg that cannot keep the token still reads, + /// unauthenticated, which is what the local Kubo `apps/web/.env.example` + /// ships needs. #[test] - fn a_tls_or_loopback_accelerator_is_handed_the_session_bearer() { + fn a_tls_accelerator_is_handed_the_session_bearer() { for base_url in [ "https://gw.cipherbox.test", - "http://localhost:8080", - "http://localhost", - "http://127.0.0.1:8080/", - "http://[::1]:8080", + "https://gw.cipherbox.test:8443", + "https://gw.cipherbox.test/path", ] { let gateway = GatewayConfig { accelerator: Some(base_url.to_owned()), diff --git a/tests/web-e2e/README.md b/tests/web-e2e/README.md index a4908b7f8..7fbe3236f 100644 --- a/tests/web-e2e/README.md +++ b/tests/web-e2e/README.md @@ -14,16 +14,12 @@ Normative source: [`blueprint/testing.md`](../../blueprint/testing.md). contents - a cold start reaches a settled, empty vault at its root; the chrome renders it, and the event taps saw the snapshot that produced it +- folder create, rename, move and delete, each ending on a drained queue that + carries no dead letter — so a write that never published fails the gate +- an upload and the file read back off the network, asserted byte for byte - signing out returns the tab to the front door - the shipping bundle exposes no introspection hook -## What it does not cover yet - -Nothing on the write path — no folder create/rename/move/delete, no upload, no -download. `blueprint/testing.md` scopes this tier at login-and-CRUD, and the -CRUD half is blocked on engine and workflow work outside this suite. The issue -tracker carries what is outstanding and why. - ## How the suite logs in There is no interactive Core Kit login in CI. The `e2e` build carries the @@ -40,10 +36,10 @@ The `release` project runs the same specs' counterpart against a bundle built Both bundles must be built before Playwright starts; the config only serves them. -1. Bring up Postgres and the record store: +1. Bring up Postgres, Kubo and the record store: ```sh - docker compose -f docker/docker-compose.yml up -d postgres mock-ipns-routing + docker compose -f docker/docker-compose.yml up -d postgres ipfs mock-ipns-routing ``` 2. Apply migrations and boot the API: @@ -52,16 +48,27 @@ them. export DB_HOST=localhost DB_PORT=5432 DB_USERNAME=postgres \ DB_PASSWORD=postgres DB_DATABASE=cipherbox NODE_ENV=test \ JWT_SECRET=web-e2e-jwt-secret THROTTLE_AUTH_LIMIT=200 \ + KUBO_API_URL=http://localhost:5001 \ CORS_ALLOWED_ORIGINS=http://localhost:4173,http://localhost:4174 pnpm --filter @cipherbox/api migration:run - pnpm --filter @cipherbox/api build && node apps/api/dist/main.js + pnpm --filter @cipherbox/api build + node apps/api/dist/main.js > /tmp/api.log 2>&1 & + curl -fsS --retry 60 --retry-connrefused --retry-delay 1 http://localhost:3000/health ``` + The API holds the shell it runs in, so it is started in the background here + and steps 3 and 4 continue in the same terminal. Run it in the foreground + instead and the rest needs a second one. + + Without `KUBO_API_URL` the API refuses every hosted upload with a 503, and + the write specs dead-letter rather than fail on an assertion. + 3. Build both bundles: ```sh export VITE_ENVIRONMENT=ci VITE_API_URL=http://localhost:3000 \ - VITE_ROUTING_ENDPOINTS=http://localhost:3001 + VITE_ROUTING_ENDPOINTS=http://localhost:3001 \ + VITE_READ_ACCELERATOR_URL=http://127.0.0.1:8080 pnpm --filter @cipherbox/web run build:wasm pnpm --filter @cipherbox/web run build:bundle mv apps/web/dist apps/web/dist-release diff --git a/tests/web-e2e/page-objects/files.page.ts b/tests/web-e2e/page-objects/files.page.ts index f55d4fc71..f85a12f0a 100644 --- a/tests/web-e2e/page-objects/files.page.ts +++ b/tests/web-e2e/page-objects/files.page.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; /** The vault browser route and the chrome around it. */ export class FilesPage { @@ -32,4 +32,78 @@ export class FilesPage { await this.page.getByTestId('user-menu').hover(); await this.page.getByTestId('logout-button').click(); } + + /** + * One listed row, picked by the accessible name its own controls carry. The + * row's text would match a substring of a longer sibling's. + */ + row(name: string): Locator { + return this.page + .getByTestId('file-list-item') + .filter({ has: this.page.getByRole('checkbox', { name: `select ${name}`, exact: true }) }); + } + + async open(name: string): Promise { + await this.row(name).dblclick(); + } + + async createFolder(name: string): Promise { + await this.page.getByTestId('new-folder-button').click(); + const dialog = this.page.getByTestId('create-folder-dialog'); + await dialog.getByLabel('folder name').fill(name); + await this.page.getByTestId('create-folder-confirm').click(); + await expect(dialog).toHaveCount(0); + } + + async rename(name: string, newName: string): Promise { + await this.act(name, 'rename'); + const dialog = this.page.getByTestId('rename-dialog'); + await dialog.getByLabel('new name').fill(newName); + await this.page.getByTestId('rename-confirm').click(); + await expect(dialog).toHaveCount(0); + } + + /** Moves a row into a subfolder of the listing it is in. */ + async move(name: string, destination: string): Promise { + await this.act(name, 'move to...'); + const dialog = this.page.getByTestId('move-dialog'); + await dialog.getByTestId('move-dialog-folder').filter({ hasText: destination }).click(); + await expect(dialog.getByTestId('move-dialog-destination')).toHaveText(destination); + await this.page.getByTestId('move-confirm').click(); + await expect(dialog).toHaveCount(0); + } + + async remove(name: string): Promise { + await this.act(name, 'delete'); + const dialog = this.page.getByTestId('delete-dialog'); + await this.page.getByTestId('delete-confirm').click(); + await expect(dialog).toHaveCount(0); + } + + /** Hands the picker one file, as a drop would. */ + async upload(name: string, bytes: Uint8Array): Promise { + await this.page.getByLabel('Choose files to upload').setInputFiles({ + name, + mimeType: 'application/octet-stream', + // Playwright's own payload type; the boundary is the only place a Buffer + // is wanted, so callers stay on Uint8Array. + buffer: Buffer.from(bytes), + }); + } + + async preview(name: string): Promise { + await this.act(name, 'preview'); + const shown = this.page.getByTestId('preview-text'); + await expect(shown).toBeVisible(); + return (await shown.textContent()) ?? ''; + } + + /** Raises a row's action menu and picks one item off it. */ + private async act(name: string, item: string): Promise { + await this.page.getByRole('button', { name: `actions for ${name}`, exact: true }).click(); + await this.page + .getByTestId('context-menu') + .getByRole('menuitem', { name: item, exact: true }) + .click(); + } } diff --git a/tests/web-e2e/page-objects/vault.page.ts b/tests/web-e2e/page-objects/vault.page.ts index dbebb1450..8a35a617d 100644 --- a/tests/web-e2e/page-objects/vault.page.ts +++ b/tests/web-e2e/page-objects/vault.page.ts @@ -1,6 +1,6 @@ import { expect, type Page } from '@playwright/test'; import type { IntrospectedView, Plain } from '@web/engine/introspection'; -import type { EventDescriptor } from '@cipherbox/client'; +import { fromHex, type EventDescriptor } from '@cipherbox/client'; /** * One browser tab over one vault, driven through the introspection hook @@ -45,12 +45,39 @@ export class VaultPage { latest = await this.page.evaluate(() => window.__CIPHERBOX_ENGINE__!.snapshot()); return latest.settled; }, - { timeout: 30_000 } + { timeout: 60_000 } ) .toBe(true); return latest; } + /** + * The same wait, after forcing one resolve-and-drain pass. A queued write + * publishes on a pass and this bundle carries the shipped 30 s cadence, so + * the footer's refresh control is what keeps a write assertion bounded by + * the engine's work rather than by that cadence. Exactly one pass: a second + * forced while the first is in flight displaces it, and the queue drains on + * neither. + */ + async settledNow(): Promise { + await this.page.getByTestId('status-indicator').click(); + return this.settled(); + } + + /** + * The plaintext the engine reads back for one child of the root, straight off + * the network. The preview decodes as UTF-8 — only these bytes can tell a byte + * the round trip changed from one the decoder folded away. + */ + async read(name: string): Promise { + const { view } = await this.settled(); + const child = view.children.find((entry) => entry.name === name); + expect(child, `the root lists no ${name}`).toBeDefined(); + return fromHex( + await this.page.evaluate((node) => window.__CIPHERBOX_ENGINE__!.download(node), child!.id) + ); + } + /** Every engine event the tab has seen so far. */ events(): Promise[]> { return this.page.evaluate(() => window.__CIPHERBOX_ENGINE__!.events()); diff --git a/tests/web-e2e/playwright.config.ts b/tests/web-e2e/playwright.config.ts index 9623a065e..157fd3f3c 100644 --- a/tests/web-e2e/playwright.config.ts +++ b/tests/web-e2e/playwright.config.ts @@ -47,7 +47,9 @@ export default defineConfig({ projects: [ { name: 'e2e', - testMatch: '**/smoke.spec.ts', + // Everything but the bundle-shape spec, so a new spec is in the gate the + // moment it lands rather than on remembering to widen a list. + testIgnore: '**/release-bundle.spec.ts', use: { ...devices['Desktop Chrome'], baseURL: url(E2E_PORT) }, }, { diff --git a/tests/web-e2e/tests/write-path.spec.ts b/tests/web-e2e/tests/write-path.spec.ts new file mode 100644 index 000000000..0452e8168 --- /dev/null +++ b/tests/web-e2e/tests/write-path.spec.ts @@ -0,0 +1,106 @@ +/** + * The write half of the smoke slice (blueprint/testing.md "E2E"): folder CRUD + * and an upload read back off the network, driven through the shipped chrome + * against the live stack. Every case asserts on a drained queue carrying no + * dead letter — a refused block store answers 503, which the drain charges as + * a spent attempt and abandons the op, so "the row is on screen" alone would + * pass over a write that never published. + */ + +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { FilesPage } from '../page-objects/files.page'; +import { VaultPage } from '../page-objects/vault.page'; + +/** Multi-byte and multi-line, so no transfer that mangles either passes. */ +const PAYLOAD = 'ciphertext round trip\n\tédition — 中文\r\nlast line without a newline'; + +const PROBE = 'probe'; + +async function coldStart(page: Page): Promise<{ vault: VaultPage; files: FilesPage }> { + const vault = new VaultPage(page); + const files = new FilesPage(page); + await vault.open(); + await vault.coldStart(); + await vault.settled(); + await expect(files.browser).toBeVisible(); + return { vault, files }; +} + +/** + * The root's published children, once the queue has drained past the write + * under test. That write is followed by a probe folder because the queue is + * strict FIFO: the probe's pending mark clears only after everything ahead of + * it published, and a write that takes its own row off the root — a delete, a + * move out — otherwise leaves a settle nothing to wait on. + */ +async function drained(files: FilesPage, vault: VaultPage): Promise { + await files.createFolder(PROBE); + const { view } = await vault.settledNow(); + expect(view.deadLetters).toEqual([]); + return view.children + .filter((child) => child.name !== PROBE) + .map((child) => `${child.kind} ${child.name}`) + .sort(); +} + +test('a created folder publishes and is listed', async ({ page }) => { + const { vault, files } = await coldStart(page); + + await files.createFolder('reports'); + + await expect(files.row('reports')).toBeVisible(); + expect(await drained(files, vault)).toEqual(['folder reports']); +}); + +test('a renamed folder publishes under its new name', async ({ page }) => { + const { vault, files } = await coldStart(page); + await files.createFolder('drafts'); + await vault.settledNow(); + + await files.rename('drafts', 'final'); + + await expect(files.row('final')).toBeVisible(); + await expect(files.row('drafts')).toHaveCount(0); + expect(await drained(files, vault)).toEqual(['folder final']); +}); + +test('a moved folder leaves the root and lists under its new parent', async ({ page }) => { + const { vault, files } = await coldStart(page); + await files.createFolder('archive'); + await files.createFolder('notes'); + await vault.settledNow(); + + await files.move('notes', 'archive'); + + await expect(files.row('notes')).toHaveCount(0); + expect(await drained(files, vault)).toEqual(['folder archive']); + + await files.open('archive'); + await expect(files.breadcrumbs).toContainText('archive'); + await expect(files.row('notes')).toBeVisible(); +}); + +test('a deleted folder leaves the listing', async ({ page }) => { + const { vault, files } = await coldStart(page); + await files.createFolder('scratch'); + await vault.settledNow(); + + await files.remove('scratch'); + + await expect(files.row('scratch')).toHaveCount(0); + expect(await drained(files, vault)).toEqual([]); +}); + +test('an uploaded file reads back byte for byte', async ({ page }) => { + const { vault, files } = await coldStart(page); + + const bytes = new TextEncoder().encode(PAYLOAD); + await files.upload('notes.txt', bytes); + + await expect(files.row('notes.txt')).toBeVisible(); + expect(await drained(files, vault)).toEqual(['file notes.txt']); + + expect(await files.preview('notes.txt')).toBe(PAYLOAD); + expect(await vault.read('notes.txt')).toEqual(bytes); +});