From 29dc65d8e109041412dadcb5a4bfbb34d48110bd Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:14:49 +0700 Subject: [PATCH 1/3] test: Playwright end-to-end test of the Inbox flow on docker compose (#18) Adds the e2e workspace package and an e2e workflow that builds the image, starts docker compose on fresh volumes and runs Playwright through MVP exit criterion 1: sign up, create the first org, upload the sample e-invoice, wait for the proposal, approve it, check the vendor, document and entry lists and the Excel export, then read the vendor, purchase document, balanced journal entry and audit rows from the database as the superuser. The ci job installs the e2e package too, so pnpm typecheck covers it, and pnpm lint now includes e2e/. Plan T9 and exit criterion 4 updated. Closes #18 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ --- .dockerignore | 2 + .github/workflows/ci.yml | 2 +- .github/workflows/e2e.yml | 52 ++++++++++ .gitignore | 4 + AGENTS.md | 1 + docs/plan/crove-os-kernel.md | 12 +-- docs/plan/crove-os-kernel.vi.html | 6 +- e2e/global-setup.ts | 19 ++++ e2e/lib/db.ts | 17 ++++ e2e/package.json | 20 ++++ e2e/playwright.config.ts | 21 ++++ e2e/tests/inbox.spec.ts | 161 ++++++++++++++++++++++++++++++ e2e/tsconfig.json | 5 + package.json | 3 +- pnpm-lock.yaml | 45 ++++++++- pnpm-workspace.yaml | 2 + 16 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/global-setup.ts create mode 100644 e2e/lib/db.ts create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/inbox.spec.ts create mode 100644 e2e/tsconfig.json diff --git a/.dockerignore b/.dockerignore index 5e551eed..f881371b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ .git .claude .playwright-mcp +e2e/test-results +e2e/playwright-report .data .env .env.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f547408..0860656a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: # Every module, also one the kernel does not import yet: typecheck and unit tests run over all of them. - name: Install dependencies - run: pnpm install --frozen-lockfile --filter "@crove/kernel-server..." --filter "@crove/app..." --filter "./modules/*" --filter "crove-monorepo" + run: pnpm install --frozen-lockfile --filter "@crove/kernel-server..." --filter "@crove/app..." --filter "./modules/*" --filter "@crove/e2e" --filter "crove-monorepo" - name: Lint run: pnpm lint diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..46bafcd1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,52 @@ +name: e2e + +on: + pull_request: + push: + branches: [main, dev] + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.ref }} + # A newer push replaces a pull request's run; a push to main or dev always finishes. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + inbox: + name: Playwright on docker compose + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '26' + + - name: Install pnpm + run: npm install -g pnpm@10.34.7 --no-fund --no-audit + + - name: Install dependencies + run: pnpm install --frozen-lockfile --filter "@crove/e2e" + + - name: Install Chromium + run: pnpm --filter @crove/e2e exec playwright install --with-deps chromium + + # Fresh volumes on every run: the runner starts empty. + - name: Build and start the stack + run: pnpm --filter @crove/e2e stack:up + + - name: Playwright + run: pnpm test:e2e + + - name: Stack logs + if: failure() + run: pnpm --filter @crove/e2e stack:logs + + - name: Stop the stack + if: always() + run: pnpm --filter @crove/e2e stack:down diff --git a/.gitignore b/.gitignore index daf1d283..475c96ea 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ next-env.d.ts # Local artifacts .data .playwright-mcp + +# Playwright output +e2e/test-results +e2e/playwright-report diff --git a/AGENTS.md b/AGENTS.md index 638f7cbc..0fcd4e55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ Crove is the open-source Business OS (AGPL). It must run with `docker compose up - `pnpm db:migrate` applies `supabase/migrations` with the built-in runner - `pnpm db:types` (needs `CROVE_TEST_ADMIN_URL`) regenerates `packages/db/src/generated/database.ts`, the Kysely types of every table `crove_tenant` may read, with Kanel from a throwaway database the migrations build. Run it after a migration that changes such a table or its grants and commit the file; CI fails when it is out of date. Insert and update types follow `crove_tenant`'s column privileges: a column the role may not write has the write type `NotGranted`, so a forbidden write (an insert into `outbox_event` or the ledger, an update of an append-only table) fails typecheck (through `values()` and `set()`; deletes, raw SQL and insert ... select are refused only at runtime); a refinement cannot lift that. Refine a column (a CHECK-constraint union, a trigger-set column) in `packages/db/src/schema.ts`, never in the generated file. A CHECK-constraint union is a const array there (`TASK_STATUSES` and its type `TaskStatus`); `packages/db/test/check-unions.db.test.ts` compares every fixed-set CHECK constraint with its array, so a migration that adds, widens or narrows one fails until the array follows and is listed in that test. - `docker compose up --build` runs the full stack on http://localhost:8080 +- `pnpm test:e2e` runs Playwright against a stack started with `pnpm --filter @crove/e2e stack:up` (compose project `crove-e2e`, port 8080; `stack:down` removes it with its volumes). Install the browser once with `pnpm --filter @crove/e2e exec playwright install chromium` ## Updating pinned images diff --git a/docs/plan/crove-os-kernel.md b/docs/plan/crove-os-kernel.md index b836fecb..e8ec8591 100644 --- a/docs/plan/crove-os-kernel.md +++ b/docs/plan/crove-os-kernel.md @@ -14,14 +14,14 @@ | | | |---|---| | Backlog | Since 2026-10-17 the backlog and its order follow the [backlog review](backlog-review-2026-10-17.md): milestones M0 (decisions and discovery), M1 (pilot deploy), M2 (monthly close loop for an accounting firm), M3 (paid pilot), then the GA gate (the External-tenant gate below, renamed; required before public self-serve sign-up). A design partner under a signed pilot agreement needs the shorter pilot readiness checklist instead. Where this plan and the review disagree about what to build next, the review wins. | -| Current stage | **MVP step 1 merged** (open source, local) on 2026-10-13 as #8, with two exit criteria still open: criterion 2, half done (the generated cross-org RLS matrix covers every table with `org_id` since #114, T8; "no write path outside `execute()`" has no automated check yet) and criterion 4 (a Playwright end-to-end test in the repo; #8 was verified by a manual `docker compose up` run). Step 2 (Crove Cloud) waits on owner approvals: spend, a DNS token, and the DOS-side redirect URL change. | -| Repo state | `main` @ `eee8662`. Since #8: the dependency upgrade (#15), and the immutable ledger, R3 approval limited to `owner` and `admin`, the token's `aal` carried on the user actor and `search_path = pg_catalog, pg_temp` pinned on every `crove_kernel` function (#71). Contents: kernel (`apps/kernel`, `packages/*`), finance module, Vite + TanStack Router SPA (`apps/app`), `docker compose` with bundled Supabase Auth, and the Next.js landing page (`apps/web`). CI: lint, typecheck, 47 unit and 11 database tests (as of #8). | +| Current stage | **MVP step 1 merged** (open source, local) on 2026-10-13 as #8, with exit criterion 2 still open, half done (the generated cross-org RLS matrix covers every table with `org_id` since #114, T8; "no write path outside `execute()`" has no automated check yet). Criterion 4 is met: the `e2e` workflow runs the Playwright end-to-end test against `docker compose up` on every PR (#18). Step 2 (Crove Cloud) waits on owner approvals: spend, a DNS token, and the DOS-side redirect URL change. | +| Repo state | `main` @ `eee8662`. Since #8: the dependency upgrade (#15), and the immutable ledger, R3 approval limited to `owner` and `admin`, the token's `aal` carried on the user actor and `search_path = pg_catalog, pg_temp` pinned on every `crove_kernel` function (#71). Contents: kernel (`apps/kernel`, `packages/*`), finance module, Vite + TanStack Router SPA (`apps/app`), `docker compose` with bundled Supabase Auth, and the Next.js landing page (`apps/web`). CI: lint, typecheck, 47 unit and 11 database tests (as of #8), and the Playwright end-to-end job (`e2e` workflow, #18). | | Body status | Consolidated on 2026-10-14: the body describes the decided design, and "on `main`" notes mark what is not implemented yet. The External-tenant gate below lists what must be true before Crove Cloud accepts an external tenant. ADR-0026, ADR-0027 and ADR-0028 (identity sessions and revocation, design C) are accepted; ADR-0028 was accepted on 2026-10-14 with conditions: its acceptance tests are the evidence for gate T1. | | Redaction | `DOS/Crove` is public. Security-sensitive details of DOS-side fixes (§11) live only in the private DOS.Me repo; those rows keep only IDs, priorities and gates. Integration contracts Crove builds against (DOS-27 events and API, DOS-7, DOS-17) stay here. | | Phase | Time-box | Status | |---|---|---| -| MVP step 1 (open source, local), see MVP below | 1 week | Merged 2026-10-13 (#8); exit criterion 2 half done (RLS matrix, T8), criteria 2 (write path) and 4 open | +| MVP step 1 (open source, local), see MVP below | 1 week | Merged 2026-10-13 (#8); exit criterion 2 half done (RLS matrix, T8), criterion 2 (write path) open; criterion 4 met (#18) | | MVP step 2 (Crove Cloud) | - | Blocked on owner approvals | | 0b Finance spike | 2 weeks, parallel | Not started | | 1 Kernel v0 hardening (residual work after the MVP) | 8 weeks | Not started | @@ -121,7 +121,7 @@ Crove Cloud accepts its first external tenant only when every row below is green | T6 | The pg-boss schema is vendored into committed migrations; no code path runs pg-boss with `migrate: true` | Committed, version-guarded migration for `crove_jobs` (`pnpm db:vendor:pgboss`); CI check that the pinned pg-boss version matches the vendored schema version; migration replay creates `crove_jobs` with no runtime DDL | The `migrate` role starts pg-boss with `migrate: true` (`migrateJobs` in `apps/kernel/src/worker.ts`) | #25 | | T7 | An S3-compatible storage adapter, shared by API and worker | `BlobStore` S3 implementation with integration tests against an S3-compatible server in CI; an end-to-end run with `api` and `worker` as separate containers and no shared volume; keys and references per §7.6 | Partly done in #110. `STORAGE=s3` selects `S3BlobStore` (`packages/kernel-runtime/src/s3-store.ts`, AWS SDK v3), built from config by `createBlobStore` in both the `api` and `worker` roles; `STORAGE=local` (the default) keeps `LocalDiskStore`. Both stores accept only `org//` and `inbound/` keys, refuse an org key that is not the sha256 of its bytes, and raise `BlobNotFoundError` for a missing blob. S3 writes are create-only (`If-None-Match: *`; on GCS `x-goog-if-generation-match: 0`) and carry `x-amz-checksum-sha256`, which the server verifies; the same bytes again dedupe, different bytes raise `BlobConflictError`, and the store reads an object back when a server does not confirm the checksum. Local disk writes are create-only too (#111: a temporary file is fsynced and hard linked to the key, so the same bytes dedupe and different bytes raise `BlobConflictError`; without hard links the store falls back to an exclusive create and warns; stale temporary files are swept at startup). The bucket policy must grant `s3:ListBucket`, or a missing key reads as 403 and `get` raises `BlobStoreAccessError`. Bodies are buffered in memory (at most 25 MB for a file). Since #109 (ADR-0013): every file row keeps the triple (key, `generation`, sha256), where `put` returns the generation (`s3:`, `gcs:`, or `content:` on local disk and unversioned buckets); every read requests that version and checks the sha256 (`readVerified`), so a swapped object is never served or parsed (S25); `S3_VERSIONING=required` (default) refuses writes the bucket returns without a version; file rows never change (guard trigger). With `STORAGE=s3` browsers upload through a single-use presigned POST to a random `staging/` key (5 minutes, 1 byte to 25 MB, grant row in `crove_kernel.file_upload`) and download through a 60-second presigned GET of the recorded version; local disk keeps streaming through the kernel, and so does a GCS endpoint until presigned POST is verified there. Tests for #109: mocked-client and offline-signing unit tests, MinIO with versioning (presigned POST, size, key and expiry refused, overwrite still reads and downloads the original), PostgreSQL plus MinIO through the HTTP API (single use, expiry, other users), the invariant suite (`file-upload.db.test.ts`) and the RLS matrix; the split stack runs on a versioned bucket with presigned URLs off, so its downloads stream through `readVerified` with the version pinned. Tests: unit tests with a mocked client and on the real SDK request; integration tests against MinIO in the CI `check` job (`CROVE_TEST_S3_URL`); files written through the API upload and inbound paths read back from a new store instance in a new API app and a new worker call, on local disk and on MinIO (`apps/kernel/test/storage.test.ts`). End-to-end run with `api` and `worker` as separate containers (#108): `docker-compose.split.yml` runs both roles from the same image with `STORAGE=s3` against MinIO and no files volume (only the worker is on the `extract` network); the CI job `split-stack` brings it up and `tools/e2e/split-stack.mjs` uploads the e-invoice and posts an inbound email through the HTTP API, waits until the worker has read both into proposals, and checks that both files are objects in the bucket with their bytes, the raw message was deleted from it, and nothing is under `/data/files` in either container (job `split-stack` in [ci.yml](../../.github/workflows/ci.yml), [first green run](https://github.com/DOS/Crove/actions/runs/38830847915/job/116749026299)). Open: per-object retention holds set by Crove (bucket defaults apply); GCS and R2 not yet run against | #26, #108, #109 | | T8 | Generated cross-org RLS matrix (MVP exit criterion 2) | The CI `db` job generates cases for every tenant table: cross-org read, insert, update and delete denied or 0 rows; no context gives 0 rows; a new tenant table without cases fails CI | Done in #114, in the invariant suite (`apps/kernel/test/invariant/rls-matrix.db.test.ts`, run by the CI `db` job and, from the base branch, by the protected-path job). The cases come from the catalog: every relation with `org_id` in any non-system schema (19 tables today, `crove_kernel` and `crove_finance`) gets its own block. Structural check: a table (not a view), RLS enabled, no runtime role owns it or acts as its owner, no role holds TRUNCATE, REFERENCES or TRIGGER or has BYPASSRLS, and either exactly the `tenant_isolation` policy of `apply_tenant_rls` or, for the listed definer-only machinery tables (`auth_session_org`, `outbox_pending`), no privilege for any role. Probes, each in its own rolled-back transaction, as `crove_app` in a member's context, `crove_worker` in system context, `crove_definer_tenant` in a context, and both login roles with no context: reading org B (0 rows, own rows visible as the control), inserting an org B row (42501 row-level security, or permission denied where the role has no INSERT; the same factory's own-org row is accepted as the control, so a denial is never a malformed row), updating org B rows or moving own rows to org B, updating and deleting with no WHERE clause (only rows the actor sees may change), deleting org B rows and TRUNCATE. A table with `org_id` and no row factory, RLS off, another permissive policy or an unlisted no-access table fails with a message naming it. The matrix found `outbox_pending` with RLS off (no role held a privilege on it, so not reachable); `20261015160000_crove_kernel_outbox_pending_rls.sql` enables it. Open: cross-legal-entity and forged-context cases wait for legal entities and the seal (T11) | #17 | -| T9 | Playwright end-to-end test (MVP exit criterion 4) | A required CI job runs `docker compose up` and Playwright through MVP exit criterion 1 (sign up, create org, upload an e-invoice, approve, lists, export) | Verified by a manual run only | #18 | +| T9 | Playwright end-to-end test (MVP exit criterion 4) | A required CI job runs `docker compose up` and Playwright through MVP exit criterion 1 (sign up, create org, upload an e-invoice, approve, lists, export) | Done (#18): the `e2e` workflow builds the image, starts `docker compose up` on fresh volumes and runs `e2e/tests/inbox.spec.ts` on every PR: sign up, create org, upload the sample e-invoice, approve, the vendor, document and entry lists, the Excel export, then the vendor, purchase document, balanced journal entry and audit rows read from the database. Open: mark the job required in branch protection (owner) | #18 | | T10 | Context re-entry guard (§4.5, item 1) | The negative tests listed in §4.5 run in the CI `db` job | Done in #103, with database tests (`apps/kernel/test/context.db.test.ts`): `enter_context` and `enter_system_context` start with `crove_kernel.context_reentry_guard()`, which raises `CONTEXT_REENTRY` (42501) when `pg_locks` shows this backend already holding the context lock (namespace `0x43524F56`, key `pg_backend_pid()`, at any level) or when `crove.org_id` or `crove.user_id` is already set, then takes the lock with `pg_try_advisory_xact_lock`. Every negative test of §4.5 item 1 runs in the CI `db` job, plus the same cases for `enter_system_context` and the key held by another backend (refused at once, not waited for). The runtime enters once per transaction (`ctx.run` reuses the context); a re-entry maps to `INTERNAL` 500. Since T11 (#73) the guard also refuses when `crove.ctx` or `crove.ctx_seal` is already set. Since #105: the `pg_locks` read is measured and kept, since no cheaper marker holds (about 13 us per entry with an idle lock table plus about 0.27 us per lock held server-wide; entries top out near 50,000/s server-wide; §4.5 item 1); the guard works unchanged on a hot standby (§3.3); a session-level lock on the guard key left on a pooled connection is dropped by the pool reset, or with the reset off closes that connection through the taint path (both tested); the runtime logs every context error on its own `[alert] ` line with the request's ids. Open: the Phase 1 load test re-checks the cost with real lock counts | #27, #105 | | T11 | Sealed context (§4.5, item 5; deferred table above) | Database tests: context settings forged or reset with `set_config` give 0 rows and failing `WITH CHECK`; the seal covers the whole context | Done in #116, with database tests (`apps/kernel/test/invariant/sealed-context.db.test.ts`): `enter_context` and `enter_system_context` write the whole derived context (org, actor kind, user, role, legal-entity scope, flags) to `crove.ctx` and seal it in `crove.ctx_seal` with HMAC-SHA256 over the context text, `pg_backend_pid()` and the transaction start, keyed by a random key no Crove role can read (§4.5 item 5). Every `ctx_*` helper, and so every RLS policy and definer, reads the context only through `ctx_verified()`. Tests as `crove_app` and `crove_worker`: forged org, legal-entity scope, user, flags, actor and role after a valid entry, a context written without `enter_context`, an edited or removed seal, and a seal replayed into a later transaction or onto another connection all raise `CONTEXT_SEAL_INVALID` on every helper, on RLS reads and writes and in definers owned by the runner and by `crove_definer_tenant` (mapped to `INTERNAL` 500); the retired `crove.org_id` and `crove.user_id` grant nothing, and without a context reads give 0 rows and writes fail; the seal matches an HMAC recomputed outside the database. Accepted residuals: a replay needs two transactions of one backend starting in the same microsecond; `ROLLBACK TO SAVEPOINT` can restore a context `enter_context` derived earlier in the same transaction (as T10 accepts; since #117 a test pins that only such a context verifies afterwards). Since #117: `@crove/db` pools reset session state (`RESET ALL`, `RESET ROLE`, session advisory locks, cursors, `LISTEN`) before a connection goes back to the pool (`DB_RESET_ON_RELEASE`, default `true`), so a session-level `set_config` of the context settings no longer denies every later checkout of that backend, and a checkout that hits `CONTEXT_REENTRY` or `CONTEXT_SEAL_INVALID` has its connection closed instead of pooled (residual: behind a transaction-mode pooler only the pooler's reset applies, §3.3); `crove_kernel.context_key_rotate()` and the runbook `docs/runbooks/context-key-rotation.md` rotate the key, tested to stop old seals verifying. Open: `le_scope` is the explicit `'*'` and `flags` are empty until legal entities and sensitivity flags exist (§4.6); no per-call re-check of current access (measured in #117: about 4.4 us to 8.8 us per helper call, roughly 2x, one call per helper per statement; left out until the semantics for a membership change inside a running transaction are decided); `command_id`, `ai_run_id` and `trace_id` are not in the context yet; the Phase 1 load test measures the cost | #73, #117 | | T12 | Sandboxed file parsing: PDF, XML and MIME parsing run in `crove-extract` (no secrets, no egress, CPU, memory and time limits) | Tests: the sandbox cannot open a network connection and has no secrets in its environment; a dependency rule stops the API and worker from importing parsers | Done in #28, #120 and #136, except the Crove Cloud deployment. `apps/extract` is the `crove-extract` image (`apps/extract/Dockerfile`: fast-xml-parser, unpdf, postal-mime, hono and zod; no database client, AI or storage SDK) and the only home of a parser, the e-invoice XML parser included (`apps/extract/src/einvoice-xml.ts`); `@crove/l10n-vn` keeps the invoice types, money helpers, checks and a first-bytes detector, with no parsing library. One endpoint, `POST /extract?kind=einvoice_xml\|pdf_text\|mime`, takes the raw bytes and answers JSON (`@crove/extract-contract`). Limits: request bodies over 25 MB (files) or 40 MB (email) are refused before they are read; each parse runs in a fresh Node process under the permission model (read access to the app code only: no network, no file writes, no child processes, no worker threads, no addons), with an empty environment, no code generation from strings, a 512 MB heap, 768 MB of resident memory (Linux watchdog; Buffers and wasm included) and a 25 s wall clock, then killed; a kill it did not send (the OOM killer) counts as memory; the body is read only once a parser slot is free, and a body that sends nothing for 2 s or is not complete after 3 s ends with a `bad_request` failure (the worker retries) and frees the slot; one parse at a time, 1 waiting, then 503, so a queued call ends within the worker's 60 s (`EXTRACT_TIMING` in the contract: every slot ahead and its own count a body read and a parse, then the answer; the service refuses settings that break this); inbound email retries a timeout or memory answer once, then drops it. It refuses to start with any variable outside an explicit allowlist (its own settings, `NODE_ENV`, and what the node image and the container runtime set), naming the variables and never their values, inside its image (a marker file written at build time, `/etc/crove-extract-image`, so overriding `NODE_ENV` cannot switch the guard off) and wherever `NODE_ENV` is not explicitly `development` or `test`; a development run (`pnpm dev`) only warns about credential-like names. On Kubernetes its pod needs `enableServiceLinks: false`. The worker requires `EXTRACT_URL`, and `extractAndPropose` without a client fails: nothing outside `crove-extract` parses, in tests too. The kernel image is installed with `--prod --filter-prod`, so no parser package is in it (`@crove/extract` is only a devDependency of the kernel, for its database tests). The worker sends the bytes and validates the answer with strict zod schemas (unknown keys, oversized fields, wrong kind or status and answers over 64 MB are refused; redirects are refused); file-caused failures are recorded on the file, service failures throw and the job retries. Compose runs it on an `internal: true` network with no published port, a read-only root file system, `cap_drop: ALL`, `no-new-privileges`, 1 GiB, 1 vCPU and 128 pids. Tests: `apps/extract/test/extract.test.ts` (limits, timeout, heap, billion laughs, deep nesting, malformed input, no network connection, no file write or process, empty environment, environment allowlist), `apps/extract/test/einvoice-xml.test.ts` (the e-invoice parser), `apps/extract/test/http.test.ts` (through `@hono/node-server`: a caller that disconnects aborts its parse or leaves the queue, a caller that holds the single slot with a body it never finishes frees it when it disconnects, and one that stays connected frees it after the body idle limit, or at the body deadline when it trickles bytes), `packages/extract-contract/test/client.test.ts`, `modules/finance/test/extract.test.ts`, the dependency rule `tools/test/parser-imports.test.ts` (sources, manifests and the kernel's whole production dependency tree), and the database tests run e-invoice upload and inbound email end to end through the service on a random port. The CI job `extract-image` builds the image, runs it as compose does on an internal network, parses real files inside the container, checks that an extra variable refuses the start under any `NODE_ENV`, and asserts that nothing in the container's network reaches the outside (IPv4 and IPv6 TCP, a raw UDP DNS query, DNS, HTTPS; IPv6 unavailable inside the namespace counts as blocked), with a control run on an ordinary network (its IPv6 probe is skipped only when that network has no IPv6 route, as on GitHub runners). The CI job `kernel-image` builds the root `Dockerfile`, loads every kernel module and the entry point inside it, and fails if any parser package is in its `node_modules`. Open: Crove Cloud deployment (deferred table above, A15); `exceljs` brings `saxes`, an XML reader the kernel never calls (it only writes workbooks) | #28, #120, #136 | @@ -280,7 +280,7 @@ The architecture and plan TL;DR follows. | PWA (direction, not MVP) | The SPA as an installable PWA with Web Push (iOS supports Web Push for installed PWAs). First client step, near zero cost; §15 already lists "PWA + Web Push later" | Phone approvals without an app store build | Mobile row below | Approvals on the phone are needed | | Mobile (direction, not MVP) | Expo (SDK 57, React Native 0.87, Expo Router). Shares the TypeScript contracts, zod schemas, oRPC client, TanStack Query and i18n messages with the web app; the UI is native and separate from shadcn. Use cases: approve Inbox proposals, push notifications, scan invoices and receipts with the camera into the Document Inbox, biometric step-up for R3 approvals. Scope like HubSpot's free iOS/Android app: an extension of the web app, not a copy | Native camera, push and biometrics with one TypeScript codebase for logic | Capacitor 8 wrapping the same SPA (reuses 100% of the UI, webview feel) | Wedge customers ask for mobile approvals or camera capture | | Desktop (direction, not MVP) | Tauri 2 (2.12) wrapping the same SPA static files: Rust shell, OS webview, small binaries, tray, native notifications, auto-update | Reuses the SPA as is; small binaries and low RAM | Electron 44 (bundled Chromium gives consistent rendering, at the cost of size and RAM; Slack and VS Code use it) | A real-time chat or other always-open module needs tray and native notifications (such a chat also needs WebSocket or SSE realtime, already planned in the Realtime row) | -| Tests | `vitest` 5.x (unit/db/browser) via `vp test`, `msw` 3.x, `fast-check` 4, latest `@playwright/test` (CI only). The Playwright end-to-end test is MVP exit criterion 4, still open | Latest stable; one toolchain | Vitest 4.1 if plugins lag | - | +| Tests | `vitest` 5.x (unit/db/browser) via `vp test`, `msw` 3.x, `fast-check` 4, latest `@playwright/test` (CI only). The Playwright end-to-end test (MVP exit criterion 4) runs in the `e2e` workflow | Latest stable; one toolchain | Vitest 4.1 if plugins lag | - | | DB tests & lint | Today: a Vitest `db` project that applies the committed migrations to plain PostgreSQL 17 with the built-in runner (a CI service container; no Supabase CLI) and tests RLS, append-only audit and ledger invariants. The generated cross-org RLS matrix (T8) runs in the invariant suite. Next: pgTAP catalog meta-tests. Later, as migrations grow: splinter, `plpgsql_check`, `libpg-query` 17 (including the plpgsql parser) for migration lint | Real migrations on real Postgres from day one; catalog meta-tests come next | - | - | | Observability |
  • OTel SDK + `pino` -> Cloud Logging/Trace/Error Reporting.
  • Alerts via Cloud Monitoring, data from the prober.
  • 10% sampling, 100% of errors kept.
  • No Sentry in v0.
  • Self-host: OTLP export or stdout.
| No dependency on the DOS collector (not deployed yet) | Datadog via the DOS collector | When the DOS collector is actually running | | Infrastructure |
  • Self-host: `docker compose up` (PostgreSQL 17, Supabase Auth, Crove api + worker + UI in one image, Mailpit); in production, the same images + any PostgreSQL 17 + S3-compatible storage.
  • Crove Cloud: GCP `crove-prod`, `crove-beta` (`asia-southeast1`): Cloud Run services, worker pool, Jobs, Artifact Registry, Secret Manager; a dedicated Supabase project per environment (ap-southeast-1, PG17; 7-day PITR on prod), not shared with DOS.
  • Vercel only for the landing page; the SPA is served by the kernel.
  • OpenTofu 1.x in the private `crove-cloud` repo, run by CI via WIF.
| One command for self-host; Cloud environments separated by IAM boundaries; own DB means no noisy neighbours and usable PITR | Worker as a service with min=max=1 | A data residency requirement | @@ -289,7 +289,7 @@ The architecture and plan TL;DR follows. **Client platforms.** Because the app shell is a static SPA and every write goes through the kernel API, desktop and mobile clients need no new backend. PWA, Mobile and Desktop above are direction only, built when their trigger is met. -**State on `main` (2026-10-14).** The MVP already uses pnpm 12 (`packageManager: pnpm@12.11.2`, `catalogMode: strict`), Node 26, TypeScript 7.0.2, oxlint 1.88, Vitest 5, Hono 4, oRPC 1.15, Kysely 0.29, pg-boss 12, Vite 8, TanStack Router and Query, `@supabase/auth-js`, `jose`, `use-intl` (#206), Tailwind 4 and shadcn on `@base-ui/react` 1.8 in `@crove/ui` (#46; `pnpm lint` rejects raw colors in the SPA). Kanel 4 + `kanel-kysely` generate the Kysely table types from the migrated database (`pnpm db:types`, #48; CI fails when `packages/db/src/generated/database.ts` is out of date): only the tables `crove_tenant` may read, so control-plane tables have no type, with hand-written refinements in `packages/db/src/schema.ts` that typecheck against the generated columns. Not adopted yet: Vite+ (scripts still call `oxlint`, `vitest` and `pnpm -r` directly), a formatter, Playwright, the vendored pg-boss schema (pg-boss creates `crove_jobs` at migrate time) and an S3 storage adapter (local disk only). Since #15 the landing page (`apps/web`) uses catalog versions (Next.js 16.4, React 19.3, Tailwind 4, TypeScript 7.0.2); its own Dockerfile still starts from `node:22-alpine`. +**State on `main` (2026-10-14).** The MVP already uses pnpm 12 (`packageManager: pnpm@12.11.2`, `catalogMode: strict`), Node 26, TypeScript 7.0.2, oxlint 1.88, Vitest 5, Hono 4, oRPC 1.15, Kysely 0.29, pg-boss 12, Vite 8, TanStack Router and Query, `@supabase/auth-js`, `jose`, `use-intl` (#206), Tailwind 4, shadcn on `@base-ui/react` 1.8 in `@crove/ui` (#46; `pnpm lint` rejects raw colors in the SPA) and Playwright 1.64 (`e2e/`, the `e2e` workflow). Kanel 4 + `kanel-kysely` generate the Kysely table types from the migrated database (`pnpm db:types`, #48; CI fails when `packages/db/src/generated/database.ts` is out of date): only the tables `crove_tenant` may read, so control-plane tables have no type, with hand-written refinements in `packages/db/src/schema.ts` that typecheck against the generated columns. Not adopted yet: Vite+ (scripts still call `oxlint`, `vitest` and `pnpm -r` directly), a formatter, the vendored pg-boss schema (pg-boss creates `crove_jobs` at migrate time) and an S3 storage adapter (local disk only). Since #15 the landing page (`apps/web`) uses catalog versions (Next.js 16.4, React 19.3, Tailwind 4, TypeScript 7.0.2); its own Dockerfile still starts from `node:22-alpine`. ## 3. Topology diff --git a/docs/plan/crove-os-kernel.vi.html b/docs/plan/crove-os-kernel.vi.html index d56a3891..7bb4830b 100644 --- a/docs/plan/crove-os-kernel.vi.html +++ b/docs/plan/crove-os-kernel.vi.html @@ -203,7 +203,7 @@

Crove OS Kernel

1. Trạng thái hiện tại

- + @@ -281,7 +281,7 @@

4. Cổng trước khách ngoài

- + @@ -313,7 +313,7 @@

5. Việc owner cần làm

6. Lộ trình

Cam kết: MVP, Phase 0b, 1, 2a, 2b (kernel + Kế toán). Phase 3-7 chỉ bắt đầu khi khách wedge đòi (Decision 2). Mỗi phase có time-box; hết hạn mà chưa đạt tiêu chí thì cắt phạm vi hoặc lập lại kế hoạch bằng văn bản. Tiêu chí hoàn thành từng phase: mục 13.

Giai đoạnMVP bước 1 đã merge (mã nguồn mở, chạy local) ngày 13/10/2026 qua PR #8, còn 2 tiêu chí hoàn thành mở: tiêu chí 2, xong một nửa (ma trận RLS chéo org sinh tự động đã phủ mọi bảng có org_id từ PR #114, T8; phần "không có đường ghi nào ngoài execute()" chưa có kiểm tra tự động) và tiêu chí 4 (test Playwright end-to-end trong repo; #8 chỉ kiểm tay bằng docker compose up). Bước 2 (Crove Cloud) chờ owner duyệt: chi tiền, token DNS, đổi redirect URL phía DOS.
Giai đoạnMVP bước 1 đã merge (mã nguồn mở, chạy local) ngày 13/10/2026 qua PR #8, còn mở tiêu chí 2, xong một nửa (ma trận RLS chéo org sinh tự động đã phủ mọi bảng có org_id từ PR #114, T8; phần "không có đường ghi nào ngoài execute()" chưa có kiểm tra tự động). Tiêu chí 4 đã đạt: workflow e2e chạy test Playwright end-to-end trên docker compose up ở mọi PR (#18). Bước 2 (Crove Cloud) chờ owner duyệt: chi tiền, token DNS, đổi redirect URL phía DOS.
Repomain @ eee8662 theo bản tiếng Anh. Sau #8: nâng thư viện (#15); #71 làm sổ cái bất biến, giới hạn duyệt R3 cho owner và admin, mang aal của token theo actor, ghim search_path cho mọi hàm crove_kernel. Gồm: kernel (apps/kernel, packages/*), module finance, SPA Vite + TanStack Router (apps/app), docker compose kèm Supabase Auth, landing page Next.js (apps/web). CI: lint, typecheck, 47 unit test và 11 test database (tính tới #8).
Thiết kếADR-0026, ADR-0027, ADR-0028 (thiết kế C, chấp nhận 14/10/2026 kèm điều kiện) đã chấp nhận; ADR-0029 chấp nhận ở mức định hướng. Phần thân plan mô tả thiết kế đã chốt; chỗ nào main chưa đạt thì bản tiếng Anh có ghi "on main".
Lược bớtRepo DOS/Crove là public: chi tiết bảo mật của các việc phía DOS chỉ nằm ở repo private DOS.Me; ở đây giữ mã việc, ưu tiên và cổng.
T6Schema pg-boss vendored vào migration, không chạy migrate: trueChưa làm
Role migrate vẫn khởi động pg-boss với migrate: true.
#25
T7Adapter lưu trữ S3-compatible dùng chung cho API và workerMột phần
PR #110: STORAGE=s3 chọn S3BlobStore, cả api và worker dựng store từ config; mặc định vẫn là local disk. Ghi kiểu chỉ tạo mới (If-None-Match: *, trên GCS là x-goog-if-generation-match: 0) kèm sha256 để server kiểm; ghi lại đúng bytes cũ thì bỏ qua, khác bytes thì báo lỗi. Có test với MinIO trong CI và test đọc lại sau khi khởi động lại từ API và worker. Đã chạy end-to-end với api và worker là hai container riêng (#108): docker-compose.split.yml chạy hai role từ cùng một image, STORAGE=s3 trên MinIO, không có volume files (chỉ worker vào mạng extract); job CI split-stack dựng stack, tools/e2e/split-stack.mjs tải hóa đơn điện tử lên và gửi một email đến qua HTTP API, chờ worker đọc cả hai thành đề xuất, rồi kiểm tra hai tệp nằm trong bucket đúng bytes, thư gốc đã bị xóa khỏi bucket và không có gì trong /data/files của cả hai container (job split-stack trong ci.yml, lần chạy xanh đầu tiên). Từ #109 (ADR-0013): mỗi tệp lưu bộ ba (key, generation, sha256); khi đọc luôn xin đúng phiên bản đã ghi và kiểm sha256, nên tệp bị tráo sau khi duyệt không bao giờ được trả về hay đọc lại (S25); mặc định bucket phải bật versioning. Với STORAGE=s3, trình duyệt tải lên qua presigned POST dùng một lần vào khóa staging/ ngẫu nhiên (5 phút, tối đa 25 MB) và tải về qua presigned GET đúng phiên bản (60 giây); local disk vẫn đi qua kernel. Còn mở: retention hold đặt theo từng object; chưa chạy thử với GCS và R2.
#26, #108, #109
T8Ma trận RLS chéo org sinh tự động (tiêu chí MVP 2)Đã xong
PR #114: test trong bộ invariant (rls-matrix.db.test.ts) đọc catalog, sinh case cho mọi bảng có org_id (19 bảng hiện nay). Mỗi bảng: kiểm cấu trúc (bật RLS, đúng policy tenant_isolation, không role runtime nào sở hữu bảng, không có quyền TRUNCATE) và thử đọc, insert, update, delete dữ liệu org B với vai crove_app, crove_worker, crove_definer_tenant và khi không có context; mỗi lần thử một transaction riêng, có case đối chứng trên org của chính mình. Bảng mới có org_id mà thiếu RLS, thiếu policy hoặc thiếu row factory thì test fail và nêu tên bảng. Ma trận phát hiện outbox_pending chưa bật RLS (không role nào có quyền nên chưa khai thác được); migration mới đã bật. Còn mở: case chéo pháp nhân và context giả mạo, chờ pháp nhân và seal (T11).
#17
T9Test Playwright end-to-end (tiêu chí MVP 4)Chưa làm
Mới kiểm tay.
#18
T9Test Playwright end-to-end (tiêu chí MVP 4)Đã xong
Workflow e2e dựng stack docker compose trên volume mới và chạy Playwright qua cả luồng (đăng ký, tạo org, tải hóa đơn, duyệt, danh sách, xuất Excel, kiểm tra dòng audit) ở mọi PR. Owner cần đặt job này là bắt buộc trong branch protection.
#18
T10Guard chặn gọi lại enter_context trong cùng transactionĐã xong
PR #103: enter_context và enter_system_context gọi guard trước tiên; nếu transaction đã vào context (advisory lock cấp transaction theo pg_backend_pid(), tra trong pg_locks) thì báo lỗi CONTEXT_REENTRY, kể cả trong transaction READ ONLY. Mọi negative test ở §4.5 chạy trong job CI db. Từ T11 (#73), guard còn từ chối khi crove.ctx hoặc crove.ctx_seal đã được đặt. Từ #105: đã đo chi phí đọc pg_locks (khoảng 13 us mỗi lần vào context khi bảng lock gần trống, cộng khoảng 0,27 us cho mỗi lock đang giữ trên toàn server; tối đa khoảng 50.000 lần vào context mỗi giây trên toàn server) và giữ nguyên, vì không có marker nào rẻ hơn mà caller không xóa được; guard chạy bình thường trên hot standby (chỉ các lệnh ghi bị từ chối); lock cấp session sót lại trên khóa của guard được pool reset gỡ, hoặc khi tắt reset thì connection đó bị đóng (đã có test); runtime ghi mỗi lỗi context thành một dòng [alert] <code> riêng kèm id của request.
#27, #105
T11Context có chữ ký HMAC (sealed context)Đã xong
PR #116: enter_context và enter_system_context ghi toàn bộ context (org, loại actor, user, role, phạm vi pháp nhân, cờ nhạy cảm) vào crove.ctx và ký HMAC-SHA256 vào crove.ctx_seal, ràng buộc với pg_backend_pid() và thời điểm bắt đầu transaction; key ngẫu nhiên nằm trong bảng mà không role Crove nào đọc được. Mọi hàm ctx_* (nên mọi RLS policy và definer) chỉ đọc context qua ctx_verified(). Test (thư mục invariant) với crove_app và crove_worker: giả mạo org, pháp nhân, user, cờ, actor, role, ghi context không qua enter_context, sửa hoặc xóa seal, phát lại seal sang transaction hoặc kết nối khác đều bị từ chối với CONTEXT_SEAL_INVALID (runtime trả INTERNAL 500); không có context thì đọc ra 0 dòng và ghi thất bại. Bổ sung ở #117: pool của @crove/db reset trạng thái session (RESET ALL, RESET ROLE, advisory lock cấp session, cursor, LISTEN) trước khi trả kết nối về pool (biến DB_RESET_ON_RELEASE, mặc định true; Cloud sau Supavisor đặt false), nên một set_config cấp session lên các setting context không còn chặn mọi lần mượn kết nối sau; kết nối nào gặp lỗi CONTEXT_REENTRY hoặc CONTEXT_SEAL_INVALID thì bị đóng thay vì trả về pool, kèm một dòng log để cảnh báo (còn lại: sau pooler chế độ transaction thì chỉ cơ chế reset của pooler có tác dụng, §3.3); có hàm crove_kernel.context_key_rotate() và runbook docs/runbooks/context-key-rotation.md để xoay key, có test chứng minh seal cũ hết hiệu lực; có test chốt rằng sau ROLLBACK TO SAVEPOINT chỉ context do chính enter_context tạo trong cùng transaction mới hợp lệ. Còn mở: le_scope tạm là '*' và chưa có cờ nhạy cảm cho đến khi có pháp nhân (§4.6); chưa kiểm lại quyền hiện tại ở mỗi lần gọi (đo ở #117: khoảng 4,4 us lên 8,8 us mỗi lần gọi helper, tức khoảng 2 lần, mỗi helper gọi một lần mỗi câu lệnh; chưa làm cho đến khi chốt cách xử lý khi membership đổi giữa một transaction đang chạy); context chưa có command_id, ai_run_id, trace_id.
#73, #117
T12Parse file (PDF, XML, MIME) trong sandbox crove-extractXong phần self-host
PR #124 (#28) và #120: image crove-extract (apps/extract) chỉ chứa thư viện parse, không DB client, không secret, và là nơi duy nhất có parser, kể cả parser XML hóa đơn điện tử (chuyển từ @crove/l10n-vn sang apps/extract). Worker gửi bytes, nhận JSON và kiểm bằng zod; bắt buộc có EXTRACT_URL; đã gỡ đường parse dự phòng trong process, gọi extractAndPropose mà không có client thì báo lỗi. Image kernel cài bằng --prod --filter-prod nên không có package parse nào; test dependency duyệt toàn bộ cây dependency production của kernel. Mỗi lần parse chạy trong một process Node riêng: không network, không ghi file, không mở process con, env rỗng, giới hạn heap 512 MB, bộ nhớ thực 768 MB và 25 giây; email bị timeout hoặc hết bộ nhớ chỉ thử lại một lần rồi bỏ. Ở production, service từ chối khởi động nếu env có biến nằm ngoài allowlist. Compose chạy nó trên network internal: true (không ra internet), root fs chỉ đọc, bỏ hết capability, 1 GiB, 1 vCPU. Body chỉ được đọc khi có slot parse trống; body không gửi gì trong 2 giây hoặc chưa xong sau 3 giây thì bị cắt với lỗi bad_request (worker thử lại) và slot được giải phóng (#136). Job CI extract-image build image, parse file thật trong container và kiểm không có egress: TCP IPv4 và IPv6, truy vấn DNS thô qua UDP, DNS, HTTPS (#136). Còn mở: triển khai trên Crove Cloud (không IAM, không egress).
#28, #120, #136
- + diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..d7e3c0b3 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,19 @@ +// Waits until the stack is ready: every migration applied (/readyz) and Supabase Auth answering through the proxy. +import type { FullConfig } from '@playwright/test'; + +const TIMEOUT_MS = 180_000; + +async function ok(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }).catch(() => null); + return response?.ok ?? false; +} + +export default async function globalSetup(config: FullConfig): Promise { + const baseURL = config.projects[0]?.use.baseURL ?? 'http://localhost:8080'; + const deadline = Date.now() + TIMEOUT_MS; + while (Date.now() < deadline) { + if ((await ok(`${baseURL}/readyz`)) && (await ok(`${baseURL}/auth/v1/health`))) return; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + throw new Error(`Crove at ${baseURL} is not ready after ${TIMEOUT_MS / 1000} s; start it with: pnpm --filter @crove/e2e stack:up`); +} diff --git a/e2e/lib/db.ts b/e2e/lib/db.ts new file mode 100644 index 00000000..009fbf5e --- /dev/null +++ b/e2e/lib/db.ts @@ -0,0 +1,17 @@ +// Reads the stack's database as the PostgreSQL superuser (no RLS), so assertions see exactly what was stored. +// Runs psql inside the compose `db` service; the database port is never published. +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const COMPOSE_FILE = fileURLToPath(new URL('../../docker-compose.yml', import.meta.url)); +const PROJECT = process.env.E2E_COMPOSE_PROJECT ?? 'crove-e2e'; + +// `query` uses psql variables (:'name') for every value in `vars`. Returns the rows as JSON objects. +export function sql(query: string, vars: Record = {}): T[] { + const args = ['compose', '-p', PROJECT, '-f', COMPOSE_FILE, 'exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A']; + args.push('-v', 'ON_ERROR_STOP=1'); + for (const [name, value] of Object.entries(vars)) args.push('-v', `${name}=${value}`); + const script = `SELECT coalesce(json_agg(row_to_json(q)), '[]'::json) FROM (${query}) q;\n`; + const out = execFileSync('docker', args, { input: script, encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] }); + return JSON.parse(out.trim()) as T[]; +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..5aa62eed --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "@crove/e2e", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "description": "Playwright end-to-end tests against the docker compose stack (MVP exit criterion 4)", + "scripts": { + "stack:up": "docker compose -p crove-e2e -f ../docker-compose.yml up -d --build", + "stack:logs": "docker compose -p crove-e2e -f ../docker-compose.yml logs --no-color", + "stack:down": "docker compose -p crove-e2e -f ../docker-compose.yml down -v", + "test": "playwright test", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "decimal.js": "catalog:", + "exceljs": "catalog:" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..0846227c --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +// Runs against a stack started with `pnpm --filter @crove/e2e stack:up` (docker compose, project crove-e2e). +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + globalSetup: './global-setup.ts', + // One stack, one flow: tests share the database, so they run one at a time. + workers: 1, + forbidOnly: Boolean(process.env.CI), + // A flaky end-to-end test is a bug to fix, not to retry. + retries: 0, + timeout: 180_000, + expect: { timeout: 15_000 }, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/e2e/tests/inbox.spec.ts b/e2e/tests/inbox.spec.ts new file mode 100644 index 00000000..32f414b9 --- /dev/null +++ b/e2e/tests/inbox.spec.ts @@ -0,0 +1,161 @@ +// MVP exit criterion 1, end to end: sign up -> create org -> upload an e-invoice -> proposal in the Inbox -> +// approve -> vendor, document and a balanced entry exist, with audit rows -> lists and the Excel export. +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; +import { Decimal } from 'decimal.js'; +import ExcelJS from 'exceljs'; +import { sql } from '../lib/db.ts'; + +const INVOICE = fileURLToPath(new URL('../../apps/extract/test/fixtures/einvoice-basic.xml', import.meta.url)); +const SELLER = 'CÔNG TY TNHH DỊCH VỤ MẪU'; +// The fixture's synthetic tax code: checksum-invalid on purpose (#157). +const SELLER_TAX_CODE = '0101234564'; +const MEMO = `HĐ C26TAB-1234 ${SELLER}`; + +const eq = (value: unknown, expected: string) => new Decimal(String(value)).equals(expected); + +test('sign up, upload an e-invoice, approve it, then find it in the lists, the export and the database', async ({ page }) => { + const email = `e2e-${Date.now()}@example.com`; + + await test.step('sign up', async () => { + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); + await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Mật khẩu').fill('e2e-password-0123'); + await page.getByRole('button', { name: 'Tạo tài khoản' }).click(); + await expect(page).toHaveURL(/\/onboarding$/); + }); + + const slug = await test.step('create the first org', async () => { + await page.getByLabel('Tên công ty hoặc hộ kinh doanh').fill('Công ty E2E'); + await page.getByRole('button', { name: 'Tạo tổ chức' }).click(); + await expect(page).toHaveURL(/\/o\/[^/]+\/inbox$/); + await expect(page.getByRole('heading', { name: 'Hộp thư chứng từ' })).toBeVisible(); + return new URL(page.url()).pathname.split('/')[2] ?? ''; + }); + + await test.step('upload the e-invoice and wait for the proposal', async () => { + await page.locator('input[type=file]').setInputFiles(INVOICE); + await expect(page.getByText('einvoice-basic.xml')).toBeVisible(); + // The worker reads the XML and raises the proposal; the Inbox polls every few seconds. + await expect(page.getByRole('link', { name: SELLER })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText('Đã có đề xuất')).toBeVisible(); + }); + + await test.step('open the proposal and approve it', async () => { + await page.getByRole('link', { name: SELLER }).click(); + await expect(page.getByRole('heading', { name: SELLER })).toBeVisible(); + await expect(page.getByText('Hóa đơn C26TAB · số 1234 · ngày 15/09/2026')).toBeVisible(); + const approve = page.getByRole('button', { name: 'Duyệt và ghi sổ' }); + await expect(approve).toBeEnabled(); + await approve.click(); + await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); + await expect(page.getByText('Không có đề xuất nào chờ duyệt')).toBeVisible(); + await page.getByRole('button', { name: 'Đã ghi sổ' }).click(); + await expect(page.getByRole('link', { name: SELLER })).toBeVisible(); + }); + + await test.step('vendor, document and entry lists', async () => { + const nav = page.getByRole('navigation'); + + await nav.getByRole('link', { name: 'Nhà cung cấp' }).click(); + const vendor = page.getByRole('row').filter({ hasText: SELLER }); + await expect(vendor).toHaveCount(1); + await expect(vendor.getByRole('cell')).toHaveText([SELLER, SELLER_TAX_CODE, /Hà Nội/, '1']); + + await nav.getByRole('link', { name: 'Chứng từ mua' }).click(); + const document = page.getByRole('row').filter({ hasText: 'C26TAB-1234' }); + await expect(document).toHaveCount(1); + await expect(document.getByRole('cell')).toHaveText(['15/09/2026', 'C26TAB-1234', SELLER, /^1\.800\.000/, /^180\.000/, /^1\.980\.000/]); + + await nav.getByRole('link', { name: 'Bút toán' }).click(); + await expect(page.getByText(MEMO)).toBeVisible(); + const line = (account: string) => page.getByRole('row').filter({ has: page.getByRole('cell', { name: new RegExp(`^${account} `) }) }); + await expect(line('1331').getByRole('cell')).toHaveText([/^1331 /, /^180\.000/, '']); + await expect(line('331').getByRole('cell')).toHaveText([/^331 /, '', /^1\.980\.000/]); + }); + + await test.step('Excel export', async () => { + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Xuất Excel' }).click(); + const file = await download; + expect(file.suggestedFilename()).toBe(`crove-but-toan-${slug}.xlsx`); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(await file.path()); + const sheet = workbook.worksheets[0]; + if (!sheet) throw new Error('the export has no worksheet'); + const header = (sheet.getRow(1).values as unknown[]).slice(1).map(String); + const col = (name: string) => header.indexOf(name) + 1; + expect(header).toEqual(expect.arrayContaining(['Số HĐ', 'MST nhà cung cấp', 'TK Nợ', 'TK Có', 'Số tiền'])); + + const rows = sheet.getRows(2, sheet.rowCount - 1) ?? []; + expect(rows.length).toBeGreaterThan(0); + let total = new Decimal(0); + for (const row of rows) { + expect(String(row.getCell(col('Số HĐ')).value)).toBe('1234'); + expect(String(row.getCell(col('MST nhà cung cấp')).value)).toBe(SELLER_TAX_CODE); + expect(String(row.getCell(col('TK Có')).value)).toBe('331'); + total = total.plus(String(row.getCell(col('Số tiền')).value)); + } + expect(total.toFixed(0)).toBe('1980000'); + }); + + await test.step('rows in the database', async () => { + const [org] = sql<{ org_id: string; user_id: string }>( + `SELECT t.org_id, u.id AS user_id + FROM crove_kernel.tenant t JOIN crove_kernel.user_account u ON u.id = t.created_by + WHERE t.slug = :'slug' AND lower(u.email) = lower(:'email')`, + { slug, email }, + ); + if (!org) throw new Error(`org ${slug} created by ${email} not found`); + const vars = { org: org.org_id, user: org.user_id }; + + const vendors = sql<{ id: string; name: string; tax_code: string }>( + `SELECT id, name, tax_code FROM crove_kernel.party WHERE org_id = :'org'`, + vars, + ); + expect(vendors).toEqual([{ id: expect.any(String), name: SELLER, tax_code: SELLER_TAX_CODE }]); + + const documents = sql<{ id: string; party_id: string; invoice_series: string; invoice_number: string; subtotal: string; vat_amount: string; total: string; created_by: string }>( + `SELECT id, party_id, invoice_series, invoice_number, subtotal::text, vat_amount::text, total::text, created_by + FROM crove_finance.purchase_document WHERE org_id = :'org'`, + vars, + ); + expect(documents).toHaveLength(1); + const doc = documents[0]; + if (!doc) throw new Error('no purchase document'); + expect(doc).toMatchObject({ party_id: vendors[0]?.id, invoice_series: 'C26TAB', invoice_number: '1234', created_by: org.user_id }); + expect(eq(doc.subtotal, '1800000') && eq(doc.vat_amount, '180000') && eq(doc.total, '1980000')).toBe(true); + + const entries = sql<{ source_type: string; source_id: string; debit: string; credit: string; lines: number }>( + `SELECT e.source_type, e.source_id, sum(l.debit)::text AS debit, sum(l.credit)::text AS credit, count(*)::int AS lines + FROM crove_finance.journal_entry e + JOIN crove_finance.journal_line l ON l.org_id = e.org_id AND l.entry_id = e.id + WHERE e.org_id = :'org' + GROUP BY e.org_id, e.id`, + vars, + ); + expect(entries).toHaveLength(1); + const entry = entries[0]; + if (!entry) throw new Error('no journal entry'); + expect(entry).toMatchObject({ source_type: 'finance.purchase_document', source_id: doc.id }); + expect(entry.lines).toBeGreaterThanOrEqual(2); + expect(eq(entry.debit, '1980000') && eq(entry.credit, '1980000')).toBe(true); + + const audit = sql<{ command: string; outcome: string; actor_type: string; by_user: boolean }>( + `SELECT command, outcome, actor_type, coalesce(actor_id = :'user'::uuid, false) AS by_user + FROM crove_kernel.audit_event WHERE org_id = :'org' ORDER BY occurred_at, id`, + vars, + ); + expect(audit).toEqual( + expect.arrayContaining([ + { command: 'kernel.file.ingest', outcome: 'ok', actor_type: 'user', by_user: true }, + { command: 'kernel.extraction.record', outcome: 'ok', actor_type: 'system', by_user: false }, + { command: 'kernel.proposal.approve', outcome: 'ok', actor_type: 'user', by_user: true }, + { command: 'finance.purchase.record', outcome: 'ok', actor_type: 'user', by_user: true }, + ]), + ); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..5d07121d --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.lib.json", + "compilerOptions": { "lib": ["ES2024", "DOM"] }, + "include": ["*.ts", "lib", "tests"] +} diff --git a/package.json b/package.json index ee1b0f95..a2d74b49 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "dev:web": "pnpm --filter @crove/web dev", "build": "pnpm -r --filter=!@crove/web build", "typecheck": "pnpm -r --filter=!@crove/web typecheck && tsc --noEmit -p tools/tsconfig.json", - "lint": "oxlint --deny-warnings apps/kernel apps/extract apps/app packages modules && node tools/ui-colors.mjs", + "lint": "oxlint --deny-warnings apps/kernel apps/extract apps/app packages modules e2e && node tools/ui-colors.mjs", "test": "vitest run --project unit", "test:db": "vitest run --project db", + "test:e2e": "pnpm --filter @crove/e2e test", "ai:conformance": "vitest run --project ai conformance", "ai:eval": "vitest run --project ai extraction-eval --silent=false", "docs:check": "node tools/docs-check.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032e83f4..5c31b6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ catalogs: '@orpc/tanstack-query': specifier: 1.15.6 version: 1.15.6 + '@playwright/test': + specifier: 1.64.0 + version: 1.64.0 '@supabase/auth-js': specifier: 2.117.3 version: 2.117.3 @@ -518,7 +521,7 @@ importers: version: 1.56.0(@types/react@19.3.0)(react@19.3.0) next: specifier: 'catalog:' - version: 16.4.0(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.4.0(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 'catalog:' version: 19.3.0 @@ -554,6 +557,18 @@ importers: specifier: 'catalog:' version: 7.0.2 + e2e: + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.64.0 + decimal.js: + specifier: 'catalog:' + version: 10.6.0 + exceljs: + specifier: 'catalog:' + version: 4.4.0 + modules/finance: dependencies: '@crove/agent': @@ -1352,6 +1367,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.64.0': + resolution: {integrity: sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2872,6 +2892,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4033,6 +4063,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.88.0': optional: true + '@playwright/test@1.64.0': + dependencies: + playwright: 1.64.0 + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -5287,7 +5321,7 @@ snapshots: neo-async@2.6.2: {} - next@16.4.0(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.0(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.4.0 '@swc/helpers': 0.5.23 @@ -5307,6 +5341,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.64.0 sharp: 0.35.5(@types/node@26.6.5) transitivePeerDependencies: - '@babel/core' @@ -5462,6 +5497,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.0: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + possible-typed-array-names@1.1.0: {} postal-mime@4.0.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7b050546..960a9f67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - 'apps/*' - 'packages/*' - 'modules/*' + - 'e2e' catalogMode: strict @@ -16,6 +17,7 @@ catalog: '@orpc/contract': 1.15.6 '@orpc/server': 1.15.6 '@orpc/tanstack-query': 1.15.6 + '@playwright/test': 1.64.0 '@supabase/auth-js': 2.117.3 '@tailwindcss/postcss': 4.3.3 '@tailwindcss/vite': 4.3.3 From e03dff39a325506707152dcc59a6949fe2c933d3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:14:51 +0700 Subject: [PATCH 2/3] test(e2e): run the browser in Vietnamese now that the SPA follows the browser language #206 (#47) picks the SPA language from the saved choice, then navigator.languages, and Playwright's Chromium reports en-US, so the Inbox test would see the English screens. Pinning the context locale to vi-VN keeps the test on the default locale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ --- e2e/playwright.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0846227c..3765fc35 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ reporter: process.env.CI ? [['list'], ['github']] : 'list', use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080', + // The SPA takes its language from the browser (#47); the tests read the Vietnamese screens. + locale: 'vi-VN', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, From f878ef30b58af3edb33edce8cf0ed9d5c4dddd40 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:14:53 +0700 Subject: [PATCH 3/3] test(e2e): follow the Inbox tabs and the document status column on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #227 the Inbox filters are tabs, and since #205 the purchase document list has a status column ("Đã ghi sổ" for a posted document). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ --- e2e/tests/inbox.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/inbox.spec.ts b/e2e/tests/inbox.spec.ts index 32f414b9..0bd23a20 100644 --- a/e2e/tests/inbox.spec.ts +++ b/e2e/tests/inbox.spec.ts @@ -52,7 +52,7 @@ test('sign up, upload an e-invoice, approve it, then find it in the lists, the e await approve.click(); await expect(page).toHaveURL(new RegExp(`/o/${slug}/inbox$`)); await expect(page.getByText('Không có đề xuất nào chờ duyệt')).toBeVisible(); - await page.getByRole('button', { name: 'Đã ghi sổ' }).click(); + await page.getByRole('tab', { name: 'Đã ghi sổ' }).click(); await expect(page.getByRole('link', { name: SELLER })).toBeVisible(); }); @@ -67,7 +67,7 @@ test('sign up, upload an e-invoice, approve it, then find it in the lists, the e await nav.getByRole('link', { name: 'Chứng từ mua' }).click(); const document = page.getByRole('row').filter({ hasText: 'C26TAB-1234' }); await expect(document).toHaveCount(1); - await expect(document.getByRole('cell')).toHaveText(['15/09/2026', 'C26TAB-1234', SELLER, /^1\.800\.000/, /^180\.000/, /^1\.980\.000/]); + await expect(document.getByRole('cell')).toHaveText(['15/09/2026', 'C26TAB-1234', SELLER, /^1\.800\.000/, /^180\.000/, /^1\.980\.000/, 'Đã ghi sổ']); await nav.getByRole('link', { name: 'Bút toán' }).click(); await expect(page.getByText(MEMO)).toBeVisible();
PhaseTime-boxTrạng tháiNội dung chính
MVP bước 1 (mã nguồn mở, local)1 tuầnĐã merge 13/10 (#8)Tiêu chí 2: phần ma trận RLS (T8) đã xong, còn phần đường ghi duy nhất qua execute(); tiêu chí 4 (Playwright, T9) còn mở.
MVP bước 1 (mã nguồn mở, local)1 tuầnĐã merge 13/10 (#8)Tiêu chí 2: phần ma trận RLS (T8) đã xong, còn phần đường ghi duy nhất qua execute(); tiêu chí 4 (Playwright, T9) đã đạt.
MVP bước 2 (Crove Cloud)-Bị chặnChờ owner duyệt chi tiền, token DNS và đổi redirect phía DOS.
0b Finance spike2 tuần, song songChưa bắt đầuDomain TS + post_journal_entry + fast-check + báo cáo TT133 B01/B02 trên dữ liệu mẫu; 1.000 post song song ra số liên tục (#58).
1 Kernel v0 hardening8 tuầnChưa bắt đầuGồm toàn bộ Cổng trước khách ngoài, phần việc còn lại sau MVP, Party và Document Inbox, hướng dẫn self-host, review bảo mật độc lập.