diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e62b80d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + cache-dependency-path: dtrack/ui/yarn.lock + + - name: Install UI dependencies + working-directory: dtrack/ui + run: yarn install --frozen-lockfile + + - name: Type-check UI + working-directory: dtrack/ui + run: yarn type-check + + - name: Lint UI (eslint, no fixes) + working-directory: dtrack/ui + run: npx eslint ./src --ext .jsx,.js,.ts,.tsx --quiet --ignore-path ./.gitignore + + - name: Check UI formatting (prettier, no fixes) + working-directory: dtrack/ui + run: npx prettier --check "./**/*.{js,jsx,ts,tsx,css,md,json}" + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 + + - name: Check test case ids are documented + run: ./tests/check-test-case-ids.sh + + stack-tests: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Configure environment (template values + debug JWT secret) + run: | + cp .env.tpl .env + mkdir -p keys + printf 'Dummy5ecr3t4D3bug0n1yN0T4Pr0D123' > keys/jwt-secret + + - name: Bootstrap the database + run: ./initdb.sh + + - name: Start the full stack in debug mode + run: make debug + + - name: Wait for PostgREST and the UI + run: | + for url in http://localhost:3000/ http://localhost:5174/; do + timeout 120 bash -c "until curl -fsS $url >/dev/null 2>&1; do sleep 2; done" + done + + - name: Database tests (pgTAP) + run: ./tests/run-db-tests.sh + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: tests/package-lock.json + + - name: Install test dependencies + working-directory: tests + run: npm ci + + - name: API tests (vitest against PostgREST) + run: ./tests/run-api-tests.sh + + - name: End-to-end tests (Playwright against the UI) + run: ./tests/run-e2e-tests.sh + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + tests/playwright-report/ + tests/test-results/ + retention-days: 7 + + - name: Dump compose logs + if: failure() + run: docker compose logs --no-color > compose-logs.txt + + - name: Upload compose logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: compose-logs + path: compose-logs.txt + retention-days: 7 diff --git a/.gitignore b/.gitignore index 0bab5a3..253e92d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ keys *.tar venv/ backup/ +tests/node_modules/ +tests/test-results/ +tests/playwright-report/ diff --git a/Makefile b/Makefile index 3bc3b3b..480b9ea 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,8 @@ tf_env = $(if $(filter $(env),staging prod),$(env),$(if $(filter $(env),null),st play_env = $(if $(filter $(env),staging prod),$(env),null) args ?= -.PHONY: precommit infra infra-destroy play help dev debug initdb down clean +.PHONY: precommit infra infra-destroy play help dev debug initdb down clean \ + test test-db test-api test-e2e precommit: pre-commit run --all-files @@ -32,6 +33,19 @@ down: clean: $(COMPOSE_DEBUG) down -v --remove-orphans +# Test targets expect the stack up in debug mode with the database +# bootstrapped (./initdb.sh && make debug). See docs/testing.md. +test: test-db test-api test-e2e + +test-db: + ./tests/run-db-tests.sh + +test-api: + ./tests/run-api-tests.sh + +test-e2e: + ./tests/run-e2e-tests.sh + infra-init: $(TERRAFORM_CMD) init -backend-config=backend.tfconf @@ -55,6 +69,10 @@ help: @echo " initdb Bootstrap the database (runs ./initdb.sh)." @echo " down Stop the local stack (data is kept)." @echo " clean Stop the local stack and delete its data volume." + @echo " test Run all test suites (needs the debug stack up + initdb)." + @echo " test-db Run the pgTAP database tests." + @echo " test-api Run the HTTP tests against PostgREST." + @echo " test-e2e Run the Playwright end-to-end tests." @echo " precommit Manually run pre-commit hooks on all files." @echo " infra-init Initialize Terraform with backend configuration." @echo " infra Apply infrastructure configuration using Terraform." diff --git a/README.md b/README.md index 8fb9f14..2b9c7bf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ See [docs/development.md](docs/development.md) for the full guide, including dev | --- | --- | | [docs/architecture.md](docs/architecture.md) | System design: schemas, roles, RLS, request lifecycle, audit | | [docs/development.md](docs/development.md) | Running and working on DTrack locally | +| [docs/testing.md](docs/testing.md) | Test strategy, how to run the suites, testing policies | +| [docs/test-cases.md](docs/test-cases.md) | Natural-language test case catalog (`TC-…` ids) | | [docs/deployment.md](docs/deployment.md) | Provisioning infrastructure and deploying | | [docs/runbooks/backup-restore.md](docs/runbooks/backup-restore.md) | Backup, test-restore and live-restore procedures | @@ -38,8 +40,9 @@ See [docs/development.md](docs/development.md) for the full guide, including dev dtrack/db/sql/ Database bootstrap SQL, executed in order by initdb.sh dtrack/ui/ react-admin single-page app (Vite + TypeScript) dtrack/dev/queries/ Helper SQL for operations (also used by restore automation) +tests/ pgTAP, API (Vitest) and E2E (Playwright) suites config/ Docker images, compose overrides, Ansible playbooks -.github/ CI/CD workflows and their Ansible playbooks +.github/ CI and operations workflows, their Ansible playbooks main.tf Terraform: AWS EC2 + Cloudflare DNS + GitHub deploy keys ``` diff --git a/docs/development.md b/docs/development.md index af806f6..7c8e8a6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,6 +85,10 @@ docker compose up -d --build react-admin # rebuild just the UI image Handy inspection queries (activity, audit log, durations) live in [dtrack/dev/queries/useful.sql](../dtrack/dev/queries/useful.sql). +With the debug stack up and the database bootstrapped, `make test` runs the +full test pyramid (or `test-db` / `test-api` / `test-e2e` individually) — see +[testing.md](testing.md). + ### Calling the API directly Any HS256 JWT signed with the debug secret works against PostgREST in debug diff --git a/docs/test-cases.md b/docs/test-cases.md new file mode 100644 index 0000000..67bbad5 --- /dev/null +++ b/docs/test-cases.md @@ -0,0 +1,191 @@ +# Test case catalog + +Every automated test references a test case id from this catalog (enforced in +CI by [tests/check-test-case-ids.sh](../tests/check-test-case-ids.sh)). The +catalog is the natural-language source of truth for *what the system promises*; +the automated suites are its executable expression. It intentionally lives +in-repo as markdown — reviewable in PRs, greppable, zero extra infrastructure — +rather than in an external test-management system (see +[testing.md](testing.md) for that trade-off). + +**Id scheme**: `TC--`. Within an area, `0xx` are database-level +cases (pgTAP), `1xx`+ are HTTP/API-level cases, and `TC-E2E-*` are browser +journeys. A journey may be implemented as several serial specs +(`TC-E2E-010a…e`). + +**Status values** +- `automated` — implemented and expected to pass +- `xfail` — implemented and expected to **fail**: it documents a genuine bug + in the application, kept per the testing policy until the bug is fixed +- `manual` — a documented procedure, not automatable in local/CI environments +- `planned` — worth automating, not yet implemented + +## Schema & roles (database) + +| Id | Test case | Status | +| --- | --- | --- | +| TC-SCH-001 | All base tables exist in `auth`/`internal` | automated | +| TC-SCH-002 | All `api` views that PostgREST serves exist | automated | +| TC-SCH-003 | API entry-point functions exist (`api.onboard`, `pre.request`, `api.timetracking_summary`) | automated | +| TC-SCH-004 | Row-level security is enabled on `auth.users` and every `internal` table | automated | +| TC-SCH-005 | Access roles `anon`, `basic`, `pm`, `lead`, `power` exist | automated | +| TC-SCH-006 | Privilege tiers are cumulative: pm/lead inherit basic, power inherits pm and lead | automated | +| TC-SCH-007 | `power` bypasses row-level security | automated | +| TC-SCH-008 | `anon` has no SELECT on data views — API access is opt-in per role | automated | + +## Authentication & onboarding + +| Id | Test case | Status | +| --- | --- | --- | +| TC-AUTH-001 | Membership predicates (`is_project_member`, `is_pm_of`) answer correctly for members and leads | automated | +| TC-AUTH-101 | Anonymous requests to data endpoints are refused | automated | +| TC-AUTH-102 | A JWT with an invalid signature is rejected with 401 | automated | +| TC-AUTH-103 | A valid JWT naming a non-existent user is rejected (role does not exist) | automated | +| TC-AUTH-104 | An authenticated user retrieves themself via `current_employee`, including roles and `is_power` | automated | +| TC-AUTH-105 | The OpenAPI description is served at the API root | automated | +| TC-ONB-101 | `POST /rpc/onboard` is callable anonymously and rejects invalid id tokens | automated | +| TC-ONB-001 | First login of an unknown Azure user onboards them (role + `auth.users` row) and the SPA proceeds | manual — needs a real Azure id token; verified in staging | + +## Employees & user management + +| Id | Test case | Status | +| --- | --- | --- | +| TC-EMP-001 | `auth.create_user` creates a NOLOGIN role, grants `basic`, inserts the users row, derives the username from the email | automated | +| TC-EMP-002 | `auth.create_user` upserts: re-creating an existing username updates profile fields without duplicating rows | automated | +| TC-EMP-003 | First/last name default from the dotted email local part | automated | +| TC-EMP-004 | `auth.del_user` rejects emails outside the allowed domain | automated | +| TC-EMP-005 | `auth.del_user` drops the role and deletes the users row for the allowed domain | automated | +| TC-EMP-101 | A power user can create an employee through `POST /employees` | automated | +| TC-EMP-102 | A newly created employee can authenticate and see themself | automated | +| TC-EMP-103 | A basic user cannot create employees | automated | +| TC-EMP-104 | An unrelated user sees only themself in the employees list | automated | +| TC-EMP-105 | A power user can update another employee's profile | automated | +| TC-EMP-106 | An employee can update their own profile fields | **xfail** — `internal.employees_upsert` writes via `INSERT … ON CONFLICT`, and only `power` has INSERT on `auth.users`, so self-updates 403 despite `update_users_policy` intending them | + +## Projects + +| Id | Test case | Status | +| --- | --- | --- | +| TC-PROJ-001 | Creating a project through `api.projects` with a lead creates membership rows and grants the lead `pm` | automated | +| TC-PROJ-002 | Removing a project's lead revokes `pm` when they lead no other project | automated | +| TC-PROJ-101 | A project created with lead, member, AoW and activity links reads back with all of them | automated | +| TC-PROJ-102 | Being made project lead is visible as the `pm` role on `current_employee` | automated | +| TC-PROJ-103 | The project lead can update their project | automated | +| TC-PROJ-104 | Unrelated users cannot see the project | automated | +| TC-PROJ-105 | A non-lead member cannot update the project | **xfail** — `select_projects_policy` lacks `FOR SELECT`, so as a permissive FOR ALL policy it authorizes member updates too (same bug as TC-RLS-012) | +| TC-PROJ-106 | Members see their project in the list | automated | + +## Teams / areas of expertise + +| Id | Test case | Status | +| --- | --- | --- | +| TC-TEAM-001 | A member cannot belong to two teams within one area of expertise | automated | +| TC-TEAM-002 | The same member may join teams in different areas of expertise | automated | +| TC-TEAM-010 | Upserting a team through `api.aoe_teams` creates hierarchy rows and grants its supervisor `lead` | automated | +| TC-TEAM-011 | Deleting a team as power removes its rows and revokes `lead` | automated | + +## Reference data (areas of work, activities) + +| Id | Test case | Status | +| --- | --- | --- | +| TC-AOW-101 | Power can create an area of work | automated | +| TC-AOW-102 | Basic users cannot create areas of work | automated | +| TC-AOW-103 | Areas of work are readable by all authenticated users | automated | +| TC-ACT-101 | Power can create an activity | automated | +| TC-ACT-102 | Basic users cannot create activities | automated | + +## Time tracking + +| Id | Test case | Status | +| --- | --- | --- | +| TC-TT-001 | Durations that are multiples of 15 minutes are accepted (including zero) | automated | +| TC-TT-002 | Durations that are not multiples of 15 minutes are rejected | automated | +| TC-TT-003 | The UI's `"N.NN hrs"` duration format parses to the intended interval | automated | +| TC-TT-101 | A member can log their own time through the API in UI duration format | automated | +| TC-TT-102 | A member cannot log time for someone else | automated | +| TC-TT-103 | A team lead sees and can log their members' entries | automated | +| TC-TT-104 | Outsiders see none of these entries | automated | +| TC-TT-105 | The API rejects durations that are not multiples of 15 minutes | automated | + +## Row-level security (cross-cutting) + +| Id | Test case | Status | +| --- | --- | --- | +| TC-RLS-001 | Members see only their own time trackings | automated | +| TC-RLS-002 | Members cannot insert time trackings for others | automated | +| TC-RLS-003 | Writes through `api.time_trackings` respect RLS and parse the UI duration format | automated | +| TC-RLS-004 | Team leads see their members' time trackings | automated | +| TC-RLS-005 | Team leads can log time for their members | automated | +| TC-RLS-006 | Outsiders see no time trackings of others | automated | +| TC-RLS-010 | Outsiders cannot see a project they don't belong to | automated | +| TC-RLS-011 | Members see their own project | automated | +| TC-RLS-012 | Non-lead members cannot update the project | **xfail** — see TC-PROJ-105 | +| TC-RLS-013 | Project leads can update their project | automated | +| TC-RLS-014 | Non-power users cannot create brand-new projects | automated | +| TC-RLS-020 | Basic users cannot create reference data | automated | +| TC-RLS-021 | Power users can create reference data | automated | +| TC-RLS-030 | Unrelated users see only themselves in `auth.users` | automated | +| TC-RLS-031 | Members also see their team lead | automated | + +## FAQs + +| Id | Test case | Status | +| --- | --- | --- | +| TC-FAQ-001 | Only published FAQs are visible to regular users (database level) | automated | +| TC-FAQ-002 | Regular users cannot create FAQs (database level) | automated | +| TC-FAQ-101 | Power can create published and draft FAQs | automated | +| TC-FAQ-102 | Basic users see only published FAQs | automated | +| TC-FAQ-103 | Basic users cannot create FAQs | automated | +| TC-FAQ-104 | Power sees drafts too | automated | + +## Dashboard plots + +| Id | Test case | Status | +| --- | --- | --- | +| TC-PLOT-101 | `duration_calendar` aggregates a user's entries | automated | +| TC-PLOT-102 | The `timetracking_summary` RPC responds for the calling user | automated | +| TC-PLOT-001 | `timetracking_summary` totals equal the sum of the underlying entries across zoom levels | planned — pgTAP | + +## UI ↔ API contract + +The PostgREST features `@raphiniert/ra-data-postgrest` and the UI's queries +rely on; they must keep working across PostgREST upgrades. + +| Id | Test case | Status | +| --- | --- | --- | +| TC-API-201 | Column aliasing via `select=id,name:username` | automated | +| TC-API-202 | JSONB containment filters on embedded relations (`areas_of_work=cs.[{"id":N}]`) | automated | +| TC-API-203 | Exact counts via `Prefer: count=exact` (Content-Range) | automated | +| TC-API-204 | Ordering by nested JSONB fields (`order=user->name.asc`) | automated | +| TC-API-205 | `Prefer: return=representation` returns the created row (note: generated ids are currently null in the representation — a quirk of the INSTEAD OF triggers) | automated | +| TC-API-206 | JSONB arrow filters used by list filters (`user->>id=eq.N`) | automated | + +## Security posture + +| Id | Test case | Status | +| --- | --- | --- | +| TC-SEC-001 | `internal.aoe_teams_grant_or_revoke_privs` is not executable by `basic` | **xfail** — it currently is (SECURITY DEFINER; flagged `TODO: potentially insecure` in the source) | + +## End-to-end journeys (browser) + +| Id | Test case | Status | +| --- | --- | --- | +| TC-E2E-001 | The power user logs in via the debug login, sees the navigation, and can log out | automated | +| TC-E2E-002 | An unknown user cannot reach the workspace | automated | +| TC-E2E-010 | Core journey (serial specs a–e): create AoW → activity → project (linked, with manager) → log time through the cascading selects → entry appears in the list | automated | +| TC-E2E-020 | The dashboard renders for a user with data | automated | +| TC-E2E-030 | Role-based UI: a plain member does not see other employees' entries or admin-only actions | planned — needs multi-user browser personas | +| TC-E2E-040 | FAQ lifecycle through the UI: create draft, publish, verify member visibility | planned | + +## UI unit level + +| Id | Test case | Status | +| --- | --- | --- | +| TC-UI-001 | CSV export mapping and id generation in `dtrack/ui/src/utils/utils.tsx` behave as specified | planned — deferred while the UI toolchain (package.json/yarn.lock feeding the production image build) is frozen; see testing.md | +| TC-UI-002 | Date-range helpers in `utils/Dropdowns.tsx` compute correct default ranges per granularity | planned — same constraint | + +## Operational procedures + +| Id | Test case | Status | +| --- | --- | --- | +| TC-OPS-001 | A production backup restores onto a fresh instance and the app works against it | manual — [runbook](runbooks/backup-restore.md); exercised continuously by the backup workflow's test-restore job | diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..1285d21 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,135 @@ +# Testing + +The current codebase is the MVP and a deliberate stability baseline (git tag +`mvp-baseline`). The test suite's job is to **pin the behavior of that +baseline** so consolidation work (docs, CI, tooling, later upgrades) can +proceed without silent regressions. Tests assert behavior contracts — who can +see and do what, what the API promises the UI — not incidental details, so +there is room to grow without rewriting tests. + +## Policies + +1. **Additions are cheap, changes are not.** Any *edit or deletion* of an + existing test must be called out and explained in the PR. A test change is + only acceptable when it legitimately improves the system, and is normally + accompanied by new tests for the adapted behavior. +2. **Genuine bugs become expected failures, not fixes.** When a test exposes a + real defect in the application, the application is *not* changed (yet); the + test is committed and marked expected-fail with the reason: + pgTAP `todo()`, vitest `test.fails`. When the defect is later fixed, the + harness reports the test as "unexpectedly passing", which forces removing + the marker — the test then guards the fix. +3. **Every automated test cites a `TC-…` id** from + [test-cases.md](test-cases.md), the natural-language catalog. CI fails if a + test references an undocumented id ([tests/check-test-case-ids.sh](../tests/check-test-case-ids.sh)). + +## The layers + +Business logic lives in PostgreSQL, so the "unit" level for most of this +system is the database itself. + +| Layer | Tool | Where | What it covers | +| --- | --- | --- | --- | +| Database (unit + RLS) | [pgTAP](https://pgtap.org) via `pg_prove` | [tests/db/](../tests/db/) | Schema/role invariants, constraints and triggers, `auth.*` functions, row-level security per persona (`SET ROLE`, exactly what PostgREST does), automatic pm/lead grant/revoke | +| API (functional) | [Vitest](https://vitest.dev) + [jose](https://github.com/panva/jose) | [tests/api/](../tests/api/) | The PostgREST surface with per-persona HS256 JWTs (debug secret): authentication, CRUD through the `api` views, RLS at HTTP level, error codes, the UI↔API contract (PostgREST query features the react-admin data provider depends on) | +| End-to-end | [Playwright](https://playwright.dev) | [tests/e2e/](../tests/e2e/) | Real browser against the debug-mode UI: login/logout, the core create-AoW→activity→project→log-time journey through the cascading selects, dashboard render | + +### Running locally + +```sh +./initdb.sh && make debug # once: stack up in debug mode, DB bootstrapped +make test # or test-db / test-api / test-e2e +``` + +Notes: + +- **DB tests** leave no trace: each file is one transaction that rolls back. + pgTAP itself is installed into the *running container* at test time (from + the PGDG apt repo already configured in the postgres image), so the + production image stays untouched. +- **API and E2E tests** write real rows. Personas (`api.*@hellodnk8n…`) are + created idempotently through the API itself; projects/teams/entries get + run-unique names (`api … ` / `E2E … `). On an ephemeral CI + database this is invisible; on a long-lived local dev database residue + accumulates — `make clean && ./initdb.sh && make debug` resets. +- Suites run sequentially (one shared database); at the current scale the + whole pyramid takes well under a minute locally once images are built. + +## CI + +[.github/workflows/ci.yml](../.github/workflows/ci.yml) runs on every PR and +push to main: + +- **lint** — UI type-check, eslint and prettier in check mode (no fixes), + pre-commit hooks (whitespace, terraform), test-case-id check. +- **stack-tests** — builds and boots the real compose stack (the same images + production runs) with template env values and the debug JWT secret, then + runs the pyramid bottom-up: pgTAP → API → E2E. On failure it uploads the + Playwright report/traces and full compose logs as artifacts. + +## Known defects pinned by expected-fail tests + +These were found while writing the baseline suite. They are real application +issues, deliberately **not fixed yet** (the application is frozen); each has an +expected-fail test that will flip when it is fixed. + +| Test case | Defect | Root cause | +| --- | --- | --- | +| TC-RLS-012 / TC-PROJ-105 | Any project member can update the project (name, dates, description) | `select_projects_policy` on `internal.projects` was created without `FOR SELECT`, making it a permissive `FOR ALL` policy whose USING clause also authorizes UPDATE/DELETE ([014.schema.policies.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/014.schema.policies.sql)) | +| TC-EMP-106 | Employees cannot edit their own profile (403) although `update_users_policy` intends self-updates | `internal.employees_upsert` writes via `INSERT … ON CONFLICT DO UPDATE`, and only `power` has INSERT on `auth.users` ([015.schema.employees.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/015.schema.employees.sql)) | +| TC-SEC-001 | `basic` can execute `internal.aoe_teams_grant_or_revoke_privs`, a SECURITY DEFINER function that grants/revokes the `lead` role | Explicit `GRANT EXECUTE … TO basic`, flagged `TODO: This is potentially insecure, fix` in [013.project_aoe_functions.sql](../dtrack/db/sql/002.postgres_user_app_admin.postgres_db_app/013.project_aoe_functions.sql) | + +Worth knowing, but pinned as current behavior rather than xfail: `Prefer: +return=representation` returns null generated ids because the INSTEAD OF +triggers return `NEW` as-is (TC-API-205), and debug-mode onboarding is +impossible by design — `api.onboard` validates real Azure tokens in-database +(TC-ONB-101/TC-ONB-001). + +## Design decisions & self-review + +Decisions made building this, with the trade-offs considered: + +1. **pgTAP installed at test time, not baked into the image.** Keeps the + production Dockerfile untouched (a hard constraint) at the cost of a + ~15 s `apt-get install` on first run per container and a network + dependency. *Revisit*: once image changes are allowed, add a test-stage + image (or a compose `test` override) so offline runs work. +2. **Markdown catalog over a test-management system.** Kiwi TCMS/TestLink + style tools add a server, accounts and drift risk for a one-team repo; + markdown + a CI grep gives ids, review-in-PR and zero infrastructure. + *Revisit* if non-engineers need to author cases or the catalog outgrows a + single file. +3. **TypeScript everywhere above SQL.** Vitest for API tests instead of + pytest/Hurl keeps one language across UI, API tests and E2E, one lockfile, + one `npm ci` in CI. The `tests/` package is fully separate from + `dtrack/ui` so the app's frozen `package.json`/`yarn.lock` (which feed the + production image build) are never touched. +4. **E2E authenticates via debug mode, not MSAL.** The debug login signs the + same JWT shape PostgREST validates in production; only the signature + scheme (HS256 shared secret vs Azure JWKS) differs, and that half is + pinned by TC-AUTH-102/103. Real-MSAL login and onboarding remain a manual + staging check (TC-ONB-001). Testing MSAL itself would mean automating a + third-party login page — brittle and out of scope. +5. **UI unit tests deferred** (TC-UI-001/002 planned). Almost all logic lives + in SQL; the UI's own pure logic is small, and adding a test runner to the + frozen UI package risks changing `yarn.lock` and therefore the production + image. The E2E journey covers the highest-risk UI behavior (the cascading + selects and data provider wiring). *Revisit* when the UI toolchain is + unfrozen — then add Vitest + Testing Library inside `dtrack/ui`. +6. **CI boots the real compose stack** rather than service containers or + mocks: the exact images production runs, bootstrapped by the real + `initdb.sh`. Slower (image build dominates; ~10 min uncached) but it tests + reality, including the bootstrap scripts themselves. *Revisit*: add Docker + layer caching (`docker/build-push-action` with GHA cache) when the cycle + time starts to hurt. +7. **Assertions are deliberately loose where the contract is fuzzy** — e.g. + "status ≥ 400" where PostgREST's exact code may change across upgrades, + exact codes (`42501`, `23514`) where they are the contract. List/count + assertions filter by run-scoped names so suites tolerate existing data. +8. **Sequential execution** (vitest `fileParallelism: false`, Playwright + `workers: 1`) because all suites share one database. Parallelizing would + need per-worker schemas or databases — not worth it at this size. + +Known gaps, on purpose: no load/performance tests, no visual regression, no +mutation testing, no property-based RLS fuzzing (a future TC-SEC series), +UI unit level empty. The catalog marks these `planned` where concrete. diff --git a/tests/api/auth.test.ts b/tests/api/auth.test.ts new file mode 100644 index 0000000..d6e9b7d --- /dev/null +++ b/tests/api/auth.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'vitest'; + +import { api, asUser, POWER_EMAIL, tokenFor } from './helpers'; + +describe('authentication and role switching', () => { + test('TC-AUTH-101: anonymous requests cannot read data views', async () => { + const res = await api('GET', '/employees'); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + expect(res.body?.code).toBe('42501'); + }); + + test('TC-AUTH-102: a token with a bad signature is rejected', async () => { + const good = await tokenFor(POWER_EMAIL); + const tampered = good.slice(0, -4) + 'AAAA'; + const res = await api('GET', '/current_employee', { token: tampered }); + expect(res.status).toBe(401); + }); + + test('TC-AUTH-103: a valid token for a non-existent user is rejected', async () => { + const ghost = await asUser('no.such.user@hellodnk8n.onmicrosoft.com'); + const res = await ghost.get('/current_employee'); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/does not exist/); + }); + + test('TC-AUTH-104: the power user sees itself via current_employee', async () => { + const power = await asUser(POWER_EMAIL); + const res = await power.get('/current_employee'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].email).toBe(POWER_EMAIL); + expect(res.body[0].is_power).toBe(true); + expect(res.body[0].roles).toContain('power'); + }); + + test('TC-AUTH-105: the OpenAPI description is served at the API root', async () => { + const res = await api('GET', '/'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('paths'); + }); + + test('TC-ONB-101: onboarding endpoint exists and rejects garbage tokens', async () => { + // Full onboarding needs a real Azure id token, which local/CI runs don't + // have; asserting the anon-callable endpoint rejects junk still pins down + // that it is exposed and validating. + const res = await api('POST', '/rpc/onboard', { body: { id_token: 'garbage' } }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/tests/api/contract.test.ts b/tests/api/contract.test.ts new file mode 100644 index 0000000..550f66e --- /dev/null +++ b/tests/api/contract.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensureProjectWorld, ProjectWorld } from './helpers'; + +/** + * The UI ↔ API contract: PostgREST features the react-admin data provider + * (@raphiniert/ra-data-postgrest) and the UI's queries depend on. If one of + * these breaks, screens break even though plain CRUD still works. + */ +let w: ProjectWorld; +beforeAll(async () => { + w = await ensureProjectWorld('contract'); +}); + +describe('UI-API contract', () => { + test('TC-API-201: column aliasing via select (id,name:username)', async () => { + const res = await w.power.get('/employees?select=id,name:username&limit=1'); + expect(res.status).toBe(200); + expect(res.body[0]).toHaveProperty('name'); + expect(res.body[0]).not.toHaveProperty('username'); + }); + + test('TC-API-202: jsonb containment filter on embedded relations', async () => { + // ProjectSelectInput filters projects by area of work this way + const filter = encodeURIComponent(`[{"id": ${w.aowId}}]`); + const res = await w.alice.get(`/projects?areas_of_work=cs.${filter}&select=id`); + expect(res.status).toBe(200); + expect(res.body.map((r: any) => r.id)).toContain(w.projectId); + }); + + test('TC-API-203: exact counts via Prefer: count=exact', async () => { + const res = await w.power.get('/employees?select=id&limit=1', { + Prefer: 'count=exact', + }); + expect(res.status).toBeLessThan(300); + const contentRange = res.headers.get('content-range'); + expect(contentRange).toBeTruthy(); + expect(contentRange).toMatch(/\/\d+$/); + }); + + test('TC-API-204: ordering by nested jsonb field (user->name)', async () => { + // TimeTrackingList sorts by user->name when showing the Employee column + const res = await w.alice.get('/time_trackings?order=user->name.asc&limit=5'); + expect(res.status).toBe(200); + }); + + test('TC-API-205: created rows come back with Prefer: return=representation', async () => { + // Note: the INSTEAD OF triggers return NEW as-is, so generated ids are + // null in the representation — a quirk the UI currently lives with. This + // test pins the shape (a row is returned), not the id. + const name = `api contract aow rep ${Date.now()}`; + const res = await w.power.post( + '/areas_of_work', + { name }, + { Prefer: 'return=representation' }, + ); + expect(res.status).toBe(201); + expect(res.body?.[0]?.name).toBe(name); + }); + + test('TC-API-206: jsonb arrow filters used by list filters (user->>id)', async () => { + const res = await w.bob.get(`/time_trackings?user->>id=eq.${w.bobId}&select=id`); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/api/employees.test.ts b/tests/api/employees.test.ts new file mode 100644 index 0000000..7550eb1 --- /dev/null +++ b/tests/api/employees.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensurePersonas, Fixtures, personaEmail } from './helpers'; + +let f: Fixtures; +beforeAll(async () => { + f = await ensurePersonas(); +}); + +describe('employees', () => { + test('TC-EMP-101: power can create an employee through the API', async () => { + // ensurePersonas already POSTs /employees; assert the result is queryable + const res = await f.power.get('/employees?username=eq.api.alice'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].email).toBe(personaEmail('alice')); + expect(res.body[0].first_name).toBe('Alice'); + }); + + test('TC-EMP-102: a newly created employee can authenticate', async () => { + const res = await f.carol.get('/current_employee?select=id,username,email'); + expect(res.status).toBe(200); + expect(res.body[0].username).toBe('api.carol'); + }); + + test('TC-EMP-103: a basic user cannot create employees', async () => { + const res = await f.carol.post('/employees', { username: 'api.mallory' }); + expect(res.status).toBeGreaterThanOrEqual(400); + const check = await f.power.get('/employees?username=eq.api.mallory'); + expect(check.body).toHaveLength(0); + }); + + test('TC-EMP-104: an unrelated user does not see other employees', async () => { + const res = await f.carol.get('/employees?select=username'); + expect(res.status).toBe(200); + const usernames = res.body.map((r: any) => r.username); + expect(usernames).toContain('api.carol'); + expect(usernames).not.toContain('api.alice'); + expect(usernames).not.toContain('api.bob'); + }); + + test('TC-EMP-105: power can update another employee profile', async () => { + const res = await f.power.patch('/employees?username=eq.api.carol', { + position: `Tester ${Date.now()}`, + }); + expect(res.status).toBeLessThan(300); + const check = await f.power.get('/employees?username=eq.api.carol&select=position'); + expect(check.body[0].position).toMatch(/^Tester /); + }); + + // KNOWN BUG (kept as expected failure, do not "fix" the test): + // update_users_policy intends self-updates (WITH CHECK email = current_user) + // but internal.employees_upsert writes via INSERT ... ON CONFLICT DO UPDATE, + // and only power has INSERT on auth.users — so basic users get 403 when + // editing their own profile. See docs/testing.md. + test.fails( + 'TC-EMP-106: employees can update their own profile fields', + async () => { + const res = await f.carol.patch('/employees?username=eq.api.carol', { + position: 'Self-updated', + }); + expect(res.status).toBeLessThan(300); + }, + ); +}); diff --git a/tests/api/faqs.test.ts b/tests/api/faqs.test.ts new file mode 100644 index 0000000..6c1f44a --- /dev/null +++ b/tests/api/faqs.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensurePersonas, Fixtures, runId } from './helpers'; + +let f: Fixtures; +beforeAll(async () => { + f = await ensurePersonas(); +}); + +describe('faqs', () => { + test('TC-FAQ-101: power can create published and draft FAQs', async () => { + for (const [q, published] of [ + [`api faq published ${runId}`, true], + [`api faq draft ${runId}`, false], + ] as const) { + const res = await f.power.post('/faqs', { + question: q, + answer: 'because the tests say so', + is_published: published, + }); + expect(res.status).toBe(201); + } + }); + + test('TC-FAQ-102: basic users see only published FAQs', async () => { + const res = await f.bob.get(`/faqs?question=like.api faq*${runId}&select=question`); + expect(res.status).toBe(200); + expect(res.body.map((r: any) => r.question)).toEqual([ + `api faq published ${runId}`, + ]); + }); + + test('TC-FAQ-103: basic users cannot create FAQs', async () => { + const res = await f.bob.post('/faqs', { + question: `api faq evil ${runId}`, + answer: 'nope', + is_published: true, + }); + expect(res.status).toBe(403); + }); + + test('TC-FAQ-104: power sees drafts too', async () => { + const res = await f.power.get( + `/faqs?question=like.api faq*${runId}&select=question`, + ); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); +}); diff --git a/tests/api/helpers.ts b/tests/api/helpers.ts new file mode 100644 index 0000000..391cbc2 --- /dev/null +++ b/tests/api/helpers.ts @@ -0,0 +1,197 @@ +import { SignJWT } from 'jose'; + +/** + * Shared plumbing for the HTTP tests against PostgREST. + * + * Tokens are signed with the debug-mode HS256 secret + * (config/docker-compose.overrides/debug.yaml), exactly like the UI's debug + * login page does, so these tests exercise the same JWT → role-switch path + * as production (only the signature scheme differs). + */ + +export const API_URI = process.env.DTRACK_TEST_API_URI ?? 'http://localhost:3000'; +const JWT_SECRET = + process.env.DTRACK_TEST_JWT_SECRET ?? 'Dummy5ecr3t4D3bug0n1yN0T4Pr0D123'; + +/** The initial power user created by initdb.sh ($POSTGRES_USER_APP_POWER). */ +export const POWER_EMAIL = + process.env.DTRACK_TEST_POWER_EMAIL ?? 'Dummy.User@example.com'; + +/** The only domain auth.del_user accepts; test personas live here. */ +export const TEST_DOMAIN = 'hellodnk8n.onmicrosoft.com'; + +/** Unique per test run, so run-scoped entities never collide with residue. */ +export const runId = Date.now().toString(36); + +export const tokenFor = (email: string): Promise => + new SignJWT({ preferred_username: email }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode(JWT_SECRET)); + +export interface ApiResponse { + status: number; + headers: Headers; + body: any; +} + +export async function api( + method: string, + path: string, + opts: { token?: string; body?: unknown; headers?: Record } = {}, +): Promise { + const headers: Record = { + Accept: 'application/json', + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...opts.headers, + }; + const res = await fetch(`${API_URI}${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + const text = await res.text(); + let body: any = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, headers: res.headers, body }; +} + +/** Convenience client bound to one user's JWT. */ +export async function asUser(email: string) { + const token = await tokenFor(email); + return { + email, + token, + get: (path: string, headers?: Record) => + api('GET', path, { token, headers }), + post: (path: string, body?: unknown, headers?: Record) => + api('POST', path, { token, body, headers }), + patch: (path: string, body?: unknown, headers?: Record) => + api('PATCH', path, { token, body, headers }), + delete: (path: string, headers?: Record) => + api('DELETE', path, { token, headers }), + }; +} +export type UserClient = Awaited>; + +/** + * Standard personas. Employee creation goes through the API itself + * (api.employees INSTEAD OF trigger → auth.create_user), which is idempotent, + * so personas are stable across runs while projects/teams are run-scoped. + */ +export const personaEmail = (name: string) => `api.${name}@${TEST_DOMAIN}`; + +export interface Fixtures { + power: UserClient; + alice: UserClient; // project lead / AoE team supervisor + bob: UserClient; // plain member of alice's project and team + carol: UserClient; // unrelated outsider + aliceId: number; + bobId: number; + carolId: number; +} + +async function employeeId(power: UserClient, username: string): Promise { + const res = await power.get(`/employees?username=eq.${username}&select=id`); + if (res.status !== 200 || !res.body?.[0]) { + throw new Error(`fixture employee ${username} missing: ${JSON.stringify(res)}`); + } + return res.body[0].id; +} + +export async function ensurePersonas(): Promise { + const power = await asUser(POWER_EMAIL); + for (const [username, first, last] of [ + ['api.alice', 'Alice', 'Lead'], + ['api.bob', 'Bob', 'Member'], + ['api.carol', 'Carol', 'Outsider'], + ]) { + const res = await power.post('/employees', { + username, + first_name: first, + last_name: last, + }); + if (res.status >= 300) { + throw new Error(`persona ${username} creation failed: ${JSON.stringify(res)}`); + } + } + return { + power, + alice: await asUser(personaEmail('alice')), + bob: await asUser(personaEmail('bob')), + carol: await asUser(personaEmail('carol')), + aliceId: await employeeId(power, 'api.alice'), + bobId: await employeeId(power, 'api.bob'), + carolId: await employeeId(power, 'api.carol'), + }; +} + +/** + * Run-scoped project world: a project led by alice with member bob, linked + * to a fresh AoW and activity, plus an AoE team where alice leads bob. + */ +export interface ProjectWorld extends Fixtures { + projectId: number; + projectName: string; + aowId: number; + activityId: number; + aoeId: number; +} + +export async function ensureProjectWorld(tag: string): Promise { + const f = await ensurePersonas(); + const name = (kind: string) => `api ${kind} ${tag} ${runId}`; + + // The INSTEAD OF triggers return NEW as-is, so return=representation gives + // back null ids for generated keys; fetch created rows by name instead. + const created = async (path: string, body: { name: string; [key: string]: unknown }) => { + const res = await f.power.post(path, body); + if (res.status >= 300) { + throw new Error(`fixture POST ${path} failed: ${JSON.stringify(res)}`); + } + const row = await f.power.get( + `${path}?name=eq.${encodeURIComponent(body.name)}&select=id,name`, + ); + if (!row.body?.[0]) { + throw new Error(`fixture ${path} row not found after create`); + } + return row.body[0]; + }; + + const aow = await created('/areas_of_work', { name: name('aow') }); + const activity = await created('/activities', { name: name('activity') }); + await created('/projects', { + name: name('project'), + description: 'fixture', + lead_ids: [f.aliceId], + member_ids: [f.bobId], + activity_ids: [activity.id], + aow_ids: [aow.id], + }); + const project = ( + await f.power.get(`/projects?name=eq.${encodeURIComponent(name('project'))}`) + ).body[0]; + + const aoe = await created('/areas_of_expertise', { name: name('aoe') }); + const team = await f.power.post('/aoe_teams', { + aoe: { id: aoe.id }, + lead: { id: f.aliceId }, + member_ids: [f.bobId], + }); + if (team.status >= 300) { + throw new Error(`fixture aoe_team failed: ${JSON.stringify(team)}`); + } + + return { + ...f, + projectId: project.id, + projectName: project.name, + aowId: aow.id, + activityId: activity.id, + aoeId: aoe.id, + }; +} diff --git a/tests/api/projects.test.ts b/tests/api/projects.test.ts new file mode 100644 index 0000000..ac89a2d --- /dev/null +++ b/tests/api/projects.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensureProjectWorld, ProjectWorld } from './helpers'; + +let w: ProjectWorld; +beforeAll(async () => { + w = await ensureProjectWorld('projects'); +}); + +describe('projects', () => { + test('TC-PROJ-101: project created with lead, member and links', async () => { + const res = await w.alice.get(`/projects?id=eq.${w.projectId}`); + expect(res.status).toBe(200); + const project = res.body[0]; + expect(project.lead_ids).toContain(w.aliceId); + expect(project.member_ids).toContain(w.bobId); + expect(project.aow_ids).toContain(w.aowId); + expect(project.activity_ids).toContain(w.activityId); + }); + + test('TC-PROJ-102: being made project lead grants the pm role', async () => { + const res = await w.alice.get('/current_employee?select=roles'); + expect(res.status).toBe(200); + expect(res.body[0].roles).toContain('pm'); + }); + + test('TC-PROJ-103: the project lead can update their project', async () => { + const res = await w.alice.patch(`/projects?id=eq.${w.projectId}`, { + description: `updated by lead ${Date.now()}`, + }); + expect(res.status).toBeLessThan(300); + const check = await w.alice.get(`/projects?id=eq.${w.projectId}&select=description`); + expect(check.body[0].description).toMatch(/^updated by lead /); + }); + + test('TC-PROJ-104: unrelated users cannot see the project', async () => { + const res = await w.carol.get(`/projects?id=eq.${w.projectId}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(0); + }); + + // KNOWN BUG (kept as expected failure, do not "fix" the test): + // select_projects_policy on internal.projects lacks FOR SELECT, so as a + // permissive FOR ALL policy it also authorizes UPDATE for any project + // member. See TC-RLS-012 in tests/db/03-rls.sql and docs/testing.md. + test.fails( + 'TC-PROJ-105: a non-lead member cannot update the project', + async () => { + await w.bob.patch(`/projects?id=eq.${w.projectId}`, { + description: 'member should not be able to write this', + }); + const check = await w.alice.get( + `/projects?id=eq.${w.projectId}&select=description`, + ); + expect(check.body[0].description).not.toBe( + 'member should not be able to write this', + ); + }, + ); + + test('TC-PROJ-106: members see their project in the list', async () => { + const res = await w.bob.get(`/projects?id=eq.${w.projectId}&select=name`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + }); +}); diff --git a/tests/api/refdata.test.ts b/tests/api/refdata.test.ts new file mode 100644 index 0000000..dcc1f3a --- /dev/null +++ b/tests/api/refdata.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensurePersonas, Fixtures, runId } from './helpers'; + +let f: Fixtures; +beforeAll(async () => { + f = await ensurePersonas(); +}); + +describe('reference data (areas of work, activities)', () => { + test('TC-AOW-101: power can create an area of work', async () => { + const res = await f.power.post('/areas_of_work', { name: `api refdata aow ${runId}` }); + expect(res.status).toBe(201); + }); + + test('TC-AOW-102: basic users cannot create areas of work', async () => { + const res = await f.bob.post('/areas_of_work', { name: `api evil aow ${runId}` }); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('42501'); + }); + + test('TC-AOW-103: areas of work are readable by all authenticated users', async () => { + const res = await f.carol.get('/areas_of_work?select=id,name'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + test('TC-ACT-101: power can create an activity', async () => { + const res = await f.power.post('/activities', { + name: `api refdata activity ${runId}`, + description: 'created by API tests', + }); + expect(res.status).toBe(201); + }); + + test('TC-ACT-102: basic users cannot create activities', async () => { + const res = await f.bob.post('/activities', { name: `api evil activity ${runId}` }); + expect(res.status).toBe(403); + }); +}); diff --git a/tests/api/timetracking.test.ts b/tests/api/timetracking.test.ts new file mode 100644 index 0000000..e1f0bae --- /dev/null +++ b/tests/api/timetracking.test.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import { ensureProjectWorld, ProjectWorld, runId } from './helpers'; + +let w: ProjectWorld; +beforeAll(async () => { + w = await ensureProjectWorld('tt'); +}); + +const today = new Date().toISOString().slice(0, 10); + +const entryFor = (userId: number, description: string) => ({ + description, + date: today, + duration: '0.25 hrs', + user: { id: userId }, + project: { id: w.projectId }, + activity: { id: w.activityId }, + aow: { id: w.aowId }, +}); + +describe('time tracking', () => { + test('TC-TT-101: a member can log their own time in UI duration format', async () => { + const res = await w.bob.post( + '/time_trackings', + entryFor(w.bobId, `api tt own ${runId}`), + { Prefer: 'return=representation' }, + ); + expect(res.status).toBe(201); + + const check = await w.bob.get( + `/time_trackings?description=eq.${encodeURIComponent(`api tt own ${runId}`)}`, + ); + expect(check.body).toHaveLength(1); + expect(check.body[0].duration).toBe('0.25 hrs'); + expect(check.body[0].user.id).toBe(w.bobId); + }); + + test('TC-TT-102: a member cannot log time for someone else', async () => { + const res = await w.bob.post( + '/time_trackings', + entryFor(w.aliceId, `api tt forged ${runId}`), + ); + expect(res.status).toBe(403); + expect(res.body?.code).toBe('42501'); + }); + + test('TC-TT-103: a team lead sees and can log their members entries', async () => { + const seen = await w.alice.get( + `/time_trackings?description=eq.${encodeURIComponent(`api tt own ${runId}`)}`, + ); + expect(seen.status).toBe(200); + expect(seen.body).toHaveLength(1); + + const logged = await w.alice.post( + '/time_trackings', + entryFor(w.bobId, `api tt bylead ${runId}`), + ); + expect(logged.status).toBe(201); + }); + + test('TC-TT-104: outsiders see none of these entries', async () => { + const res = await w.carol.get( + `/time_trackings?description=like.api tt*${runId}`, + ); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(0); + }); + + test('TC-TT-105: durations must be multiples of 15 minutes', async () => { + const res = await w.bob.post('/time_trackings', { + ...entryFor(w.bobId, `api tt invalid ${runId}`), + duration: '20 minutes', + }); + expect(res.status).toBe(400); + expect(res.body?.code).toBe('23514'); + }); + + test('TC-PLOT-101: duration calendar aggregates the user entries', async () => { + const res = await w.bob.get(`/duration_calendar?user_id=eq.${w.bobId}`); + expect(res.status).toBe(200); + expect(res.body.length).toBeGreaterThanOrEqual(1); + }); + + test('TC-PLOT-102: timetracking_summary RPC responds for the caller', async () => { + const res = await w.bob.get( + '/rpc/timetracking_summary?axes=%7Buser,daterange%7D&date_part=day&zoom_level=2', + ); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); diff --git a/tests/check-test-case-ids.sh b/tests/check-test-case-ids.sh new file mode 100755 index 0000000..da6e1ea --- /dev/null +++ b/tests/check-test-case-ids.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Every TC-… id referenced by an automated test must be documented in +# docs/test-cases.md. (One-way check: the catalog may also contain manual and +# planned cases that have no automation yet.) +set -euo pipefail +cd "$(dirname "$0")/.." + +catalog=docs/test-cases.md +missing=0 + +ids=$(grep -rhoE 'TC-[A-Z]+-[0-9]+' tests/db tests/api tests/e2e | sort -u) +for id in $ids; do + if ! grep -q "$id" "$catalog"; then + echo "MISSING from $catalog: $id" + missing=1 + fi +done + +if [ "$missing" -ne 0 ]; then + exit 1 +fi +echo "All referenced test case ids are documented in $catalog." diff --git a/tests/db/00-schema.sql b/tests/db/00-schema.sql new file mode 100644 index 0000000..d515086 --- /dev/null +++ b/tests/db/00-schema.sql @@ -0,0 +1,89 @@ +-- Schema sanity: the objects the API and UI depend on exist and are guarded. +-- Deliberately loose (existence + security posture), so the schema can grow +-- without breaking these tests. Test case ids: docs/test-cases.md +BEGIN; + +SELECT plan(38); + +-- TC-SCH-001: base tables exist +SELECT has_table('auth', 'users', 'TC-SCH-001: auth.users exists'); +SELECT has_table('internal', 'activities', 'TC-SCH-001: internal.activities exists'); +SELECT has_table('internal', 'areas_of_work', 'TC-SCH-001: internal.areas_of_work exists'); +SELECT has_table('internal', 'projects', 'TC-SCH-001: internal.projects exists'); +SELECT has_table('internal', 'project_activities', 'TC-SCH-001: internal.project_activities exists'); +SELECT has_table('internal', 'project_areas_of_work', 'TC-SCH-001: internal.project_areas_of_work exists'); +SELECT has_table('internal', 'user_project_roles', 'TC-SCH-001: internal.user_project_roles exists'); +SELECT has_table('internal', 'time_trackings', 'TC-SCH-001: internal.time_trackings exists'); +SELECT has_table('internal', 'areas_of_expertise', 'TC-SCH-001: internal.areas_of_expertise exists'); +SELECT has_table('internal', 'aoe_hierarchies', 'TC-SCH-001: internal.aoe_hierarchies exists'); +SELECT has_table('internal', 'faqs', 'TC-SCH-001: internal.faqs exists'); + +-- TC-SCH-002: api views exist (the PostgREST surface) +SELECT has_view('api', 'employees', 'TC-SCH-002: api.employees exists'); +SELECT has_view('api', 'current_employee', 'TC-SCH-002: api.current_employee exists'); +SELECT has_view('api', 'all_employees', 'TC-SCH-002: api.all_employees exists'); +SELECT has_view('api', 'activities', 'TC-SCH-002: api.activities exists'); +SELECT has_view('api', 'areas_of_work', 'TC-SCH-002: api.areas_of_work exists'); +SELECT has_view('api', 'projects', 'TC-SCH-002: api.projects exists'); +SELECT has_view('api', 'areas_of_expertise', 'TC-SCH-002: api.areas_of_expertise exists'); +SELECT has_view('api', 'aoe_teams', 'TC-SCH-002: api.aoe_teams exists'); +SELECT has_view('api', 'time_trackings', 'TC-SCH-002: api.time_trackings exists'); +SELECT has_view('api', 'faqs', 'TC-SCH-002: api.faqs exists'); +SELECT has_view('api', 'duration_calendar', 'TC-SCH-002: api.duration_calendar exists'); + +-- TC-SCH-003: API entry-point functions exist +SELECT has_function('api', 'onboard', ARRAY['text'], 'TC-SCH-003: api.onboard exists'); +SELECT has_function('pre', 'request', 'TC-SCH-003: pre.request exists'); +SELECT has_function('api', 'timetracking_summary', 'TC-SCH-003: api.timetracking_summary exists'); + +-- TC-SCH-004: row-level security is enabled on all guarded tables +SELECT ok( + (SELECT bool_and(relrowsecurity) + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE (n.nspname, c.relname) IN ( + ('auth', 'users'), + ('internal', 'projects'), + ('internal', 'user_project_roles'), + ('internal', 'project_areas_of_work'), + ('internal', 'areas_of_work'), + ('internal', 'project_activities'), + ('internal', 'activities'), + ('internal', 'time_trackings'), + ('internal', 'areas_of_expertise'), + ('internal', 'aoe_hierarchies'), + ('internal', 'faqs') + )), + 'TC-SCH-004: RLS enabled on auth.users and all internal tables' +); + +-- TC-SCH-005: access roles exist +SELECT has_role('anon', 'TC-SCH-005: role anon exists'); +SELECT has_role('basic', 'TC-SCH-005: role basic exists'); +SELECT has_role('pm', 'TC-SCH-005: role pm exists'); +SELECT has_role('lead', 'TC-SCH-005: role lead exists'); +SELECT has_role('power', 'TC-SCH-005: role power exists'); + +-- TC-SCH-006: privilege tiers are cumulative +SELECT is_member_of('basic', 'pm', 'TC-SCH-006: pm inherits basic'); +SELECT is_member_of('basic', 'lead', 'TC-SCH-006: lead inherits basic'); +SELECT is_member_of('pm', 'power', 'TC-SCH-006: power inherits pm'); +SELECT is_member_of('lead', 'power', 'TC-SCH-006: power inherits lead'); + +-- TC-SCH-007: power bypasses row-level security +SELECT ok( + (SELECT rolbypassrls FROM pg_roles WHERE rolname = 'power'), + 'TC-SCH-007: power has BYPASSRLS' +); + +-- TC-SCH-008: anon has no access to data views (API surface is opt-in) +SELECT ok( + NOT has_table_privilege('anon', 'api.employees', 'SELECT'), + 'TC-SCH-008: anon cannot select api.employees' +); +SELECT ok( + NOT has_table_privilege('anon', 'api.time_trackings', 'SELECT'), + 'TC-SCH-008: anon cannot select api.time_trackings' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/db/01-constraints.sql b/tests/db/01-constraints.sql new file mode 100644 index 0000000..c93f8ad --- /dev/null +++ b/tests/db/01-constraints.sql @@ -0,0 +1,76 @@ +-- Data-integrity constraints and triggers on the internal tables. +-- Test case ids: docs/test-cases.md +BEGIN; + +SELECT plan(9); + +-- Fixture: one user and the reference rows a time tracking needs +SELECT auth.create_user('fixture.user@hellodnk8n.onmicrosoft.com'); +INSERT INTO internal.activities (name) VALUES ('fixture activity'); +INSERT INTO internal.areas_of_work (name) VALUES ('fixture aow'); +INSERT INTO internal.projects (name) VALUES ('fixture project'); + +CREATE FUNCTION pg_temp.fixture_tt_insert(dur interval) RETURNS void LANGUAGE sql AS $$ + INSERT INTO internal.time_trackings (date, duration, user_id, project_id, activity_id, aow_id) + VALUES ( + CURRENT_DATE, + dur, + (SELECT user_id FROM auth.users WHERE username = 'fixture.user'), + (SELECT project_id FROM internal.projects WHERE name = 'fixture project'), + (SELECT activity_id FROM internal.activities WHERE name = 'fixture activity'), + (SELECT aow_id FROM internal.areas_of_work WHERE name = 'fixture aow') + ); +$$; + +-- TC-TT-001: durations that are multiples of 15 minutes are accepted +SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('15 minutes')$$, + 'TC-TT-001: 15 minute duration accepted'); +SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('7.5 hours')$$, + 'TC-TT-001: 7.5 hour duration accepted'); +SELECT lives_ok($$SELECT pg_temp.fixture_tt_insert('0 minutes')$$, + 'TC-TT-001: zero duration accepted (in-lieu-of-overtime convention)'); + +-- TC-TT-002: durations that are not multiples of 15 minutes are rejected +SELECT throws_ok($$SELECT pg_temp.fixture_tt_insert('20 minutes')$$, '23514', + NULL, 'TC-TT-002: 20 minute duration violates duration_check'); +SELECT throws_ok($$SELECT pg_temp.fixture_tt_insert('1 hour 1 minute')$$, '23514', + NULL, 'TC-TT-002: 61 minute duration violates duration_check'); + +-- TC-TT-003: the UI's duration format parses to the intended interval +SELECT is('0.25 hrs'::interval, '15 minutes'::interval, + 'TC-TT-003: "0.25 hrs" parses as 15 minutes'); +SELECT is('7.50 hrs'::interval, '450 minutes'::interval, + 'TC-TT-003: "7.50 hrs" parses as 7.5 hours'); + +-- TC-TEAM-001: a member cannot belong to two teams within one area of expertise +SELECT auth.create_user('fixture.lead2@hellodnk8n.onmicrosoft.com'); +INSERT INTO internal.areas_of_expertise (name) VALUES ('fixture aoe'); +INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id) +SELECT aoe_id, + (SELECT user_id FROM auth.users WHERE username = 'fixture.lead2'), + (SELECT user_id FROM auth.users WHERE username = 'fixture.user') +FROM internal.areas_of_expertise WHERE name = 'fixture aoe'; + +SELECT throws_ok($$ + INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id) + SELECT aoe_id, + (SELECT user_id FROM auth.users WHERE username = 'fixture.user'), + (SELECT user_id FROM auth.users WHERE username = 'fixture.user') + FROM internal.areas_of_expertise WHERE name = 'fixture aoe' + $$, + 'A member cannot belong to multiple teams within the same area of expertise', + 'TC-TEAM-001: second team membership in same AoE rejected'); + +-- TC-TEAM-002: the same member may join teams in different areas of expertise +INSERT INTO internal.areas_of_expertise (name) VALUES ('fixture aoe 2'); +SELECT lives_ok($$ + INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id) + SELECT aoe_id, + (SELECT user_id FROM auth.users WHERE username = 'fixture.lead2'), + (SELECT user_id FROM auth.users WHERE username = 'fixture.user') + FROM internal.areas_of_expertise WHERE name = 'fixture aoe 2' + $$, + 'TC-TEAM-002: membership in a second AoE accepted'); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/db/02-user-management.sql b/tests/db/02-user-management.sql new file mode 100644 index 0000000..460a128 --- /dev/null +++ b/tests/db/02-user-management.sql @@ -0,0 +1,76 @@ +-- auth.* user and membership management functions. +-- Test case ids: docs/test-cases.md +BEGIN; + +SELECT plan(12); + +-- TC-EMP-001: create_user creates a NOLOGIN role, grants basic, inserts auth.users +SELECT auth.create_user('test.person@hellodnk8n.onmicrosoft.com', 'Test', 'Person', 'Engineer'); +SELECT has_role('test.person@hellodnk8n.onmicrosoft.com', + 'TC-EMP-001: role created for new user'); +SELECT is_member_of('basic', 'test.person@hellodnk8n.onmicrosoft.com', + 'TC-EMP-001: new user is granted basic'); +SELECT results_eq( + $$SELECT username, first_name, last_name, position FROM auth.users + WHERE email = 'test.person@hellodnk8n.onmicrosoft.com'$$, + $$VALUES ('test.person'::text, 'Test'::text, 'Person'::text, 'Engineer'::text)$$, + 'TC-EMP-001: auth.users row created, username derived from email'); + +-- TC-EMP-002: create_user is an upsert on username +SELECT auth.create_user('test.person@hellodnk8n.onmicrosoft.com', 'Renamed', 'Person', 'Manager'); +SELECT results_eq( + $$SELECT first_name, position FROM auth.users + WHERE email = 'test.person@hellodnk8n.onmicrosoft.com'$$, + $$VALUES ('Renamed'::text, 'Manager'::text)$$, + 'TC-EMP-002: re-creating an existing user updates profile fields'); +SELECT is( + (SELECT count(*)::int FROM auth.users WHERE username = 'test.person'), + 1, 'TC-EMP-002: no duplicate row on upsert'); + +-- TC-EMP-003: create_user derives first/last name from dotted email local part +SELECT auth.create_user('jane.doe@hellodnk8n.onmicrosoft.com'); +SELECT results_eq( + $$SELECT first_name, last_name FROM auth.users WHERE username = 'jane.doe'$$, + $$VALUES ('jane'::text, 'doe'::text)$$, + 'TC-EMP-003: names default from email local part'); + +-- TC-EMP-004: del_user refuses emails outside the allowed domain +SELECT throws_ok( + $$SELECT auth.del_user('someone@example.com')$$, + 'Invalid email format: someone@example.com', + 'TC-EMP-004: del_user rejects foreign domains'); + +-- TC-EMP-005: del_user drops role and row for the allowed domain +SELECT auth.del_user('jane.doe@hellodnk8n.onmicrosoft.com'); +SELECT hasnt_role('jane.doe@hellodnk8n.onmicrosoft.com', 'TC-EMP-005: role dropped'); +SELECT is_empty( + $$SELECT 1 FROM auth.users WHERE username = 'jane.doe'$$, + 'TC-EMP-005: auth.users row deleted'); + +-- TC-AUTH-001: membership predicates +SELECT auth.create_user('pred.lead@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('pred.member@hellodnk8n.onmicrosoft.com'); +INSERT INTO internal.projects (name) VALUES ('pred project'); +INSERT INTO internal.user_project_roles (user_id, project_id, is_lead) +VALUES + ((SELECT user_id FROM auth.users WHERE username = 'pred.lead'), + (SELECT project_id FROM internal.projects WHERE name = 'pred project'), TRUE), + ((SELECT user_id FROM auth.users WHERE username = 'pred.member'), + (SELECT project_id FROM internal.projects WHERE name = 'pred project'), FALSE); + +SELECT ok(auth.is_project_member( + (SELECT user_id FROM auth.users WHERE username = 'pred.member'), + (SELECT project_id FROM internal.projects WHERE name = 'pred project')), + 'TC-AUTH-001: is_project_member true for member'); +SELECT ok(NOT auth.is_project_member( + (SELECT user_id FROM auth.users WHERE username = 'pred.member'), + (SELECT project_id FROM internal.projects WHERE name = 'pred project'), + is_lead => true), + 'TC-AUTH-001: is_project_member(is_lead) false for plain member'); +SELECT ok(auth.is_pm_of( + (SELECT user_id FROM auth.users WHERE username = 'pred.lead'), + (SELECT user_id FROM auth.users WHERE username = 'pred.member')), + 'TC-AUTH-001: is_pm_of true for lead over member of same project'); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/db/03-rls.sql b/tests/db/03-rls.sql new file mode 100644 index 0000000..2744f2d --- /dev/null +++ b/tests/db/03-rls.sql @@ -0,0 +1,219 @@ +-- Row-level security: who can see and write what. +-- Runs as superuser and impersonates personas with SET ROLE, exactly as +-- PostgREST does after validating a JWT. Test case ids: docs/test-cases.md +BEGIN; + +SELECT plan(19); + +-- Fixture personas: a team lead, their team member, an unrelated outsider, +-- and a power user. Everything rolls back at the end. +SELECT auth.create_user('rls.lead@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('rls.member@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('rls.outsider@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('rls.power@hellodnk8n.onmicrosoft.com'); +SELECT auth.grant_power('rls.power@hellodnk8n.onmicrosoft.com'); + +INSERT INTO internal.areas_of_expertise (name) VALUES ('rls aoe'); +INSERT INTO internal.aoe_hierarchies (aoe_id, lead_id, member_id) +SELECT aoe_id, + (SELECT user_id FROM auth.users WHERE username = 'rls.lead'), + (SELECT user_id FROM auth.users WHERE username = 'rls.member') +FROM internal.areas_of_expertise WHERE name = 'rls aoe'; + +INSERT INTO internal.projects (name) VALUES ('rls project'); +INSERT INTO internal.user_project_roles (user_id, project_id, is_lead) +VALUES + ((SELECT user_id FROM auth.users WHERE username = 'rls.lead'), + (SELECT project_id FROM internal.projects WHERE name = 'rls project'), TRUE), + ((SELECT user_id FROM auth.users WHERE username = 'rls.member'), + (SELECT project_id FROM internal.projects WHERE name = 'rls project'), FALSE); + +INSERT INTO internal.activities (name) VALUES ('rls activity'); +INSERT INTO internal.areas_of_work (name) VALUES ('rls aow'); + +INSERT INTO internal.time_trackings (description, date, duration, user_id, project_id, activity_id, aow_id) +SELECT descr, CURRENT_DATE, '30 minutes', + (SELECT user_id FROM auth.users WHERE username = uname), + (SELECT project_id FROM internal.projects WHERE name = 'rls project'), + (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'), + (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow') +FROM (VALUES ('rls entry member', 'rls.member'), ('rls entry lead', 'rls.lead')) AS f(descr, uname); + +INSERT INTO internal.faqs (question, answer, is_published) +VALUES ('rls published?', 'yes', TRUE), ('rls draft?', 'no', FALSE); + +--------------------------------------------------------------- +-- Time trackings +--------------------------------------------------------------- +SET ROLE "rls.member@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-001: members see only their own time trackings +SELECT results_eq( + $$SELECT description FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$, + $$VALUES ('rls entry member'::text)$$, + 'TC-RLS-001: member sees only own time trackings'); + +-- TC-RLS-002: members cannot log time for someone else +SELECT throws_ok($$ + INSERT INTO internal.time_trackings (date, duration, user_id, project_id, activity_id, aow_id) + SELECT CURRENT_DATE, '15 minutes', + (SELECT user_id FROM auth.users WHERE email = 'rls.lead@hellodnk8n.onmicrosoft.com'), + (SELECT project_id FROM internal.projects WHERE name = 'rls project'), + (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'), + (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow') + $$, '42501', NULL, + 'TC-RLS-002: member cannot insert a time tracking for their lead'); + +-- TC-RLS-003: writes through the api view respect RLS and parse the UI duration format +SELECT lives_ok($$ + INSERT INTO api.time_trackings (description, date, duration, "user", project, activity, aow) + SELECT 'rls entry via api', CURRENT_DATE, '0.25 hrs', + jsonb_build_object('id', (SELECT user_id FROM auth.users WHERE username = 'rls.member')), + jsonb_build_object('id', (SELECT project_id FROM internal.projects WHERE name = 'rls project')), + jsonb_build_object('id', (SELECT activity_id FROM internal.activities WHERE name = 'rls activity')), + jsonb_build_object('id', (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow')) + $$, + 'TC-RLS-003: member can log own time through api view'); +SELECT results_eq( + $$SELECT duration FROM internal.time_trackings WHERE description = 'rls entry via api'$$, + $$VALUES ('15 minutes'::interval)$$, + 'TC-RLS-003: "0.25 hrs" stored as 15 minutes'); + +RESET ROLE; +SET ROLE "rls.lead@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-004: team leads see their members' time trackings +SELECT results_eq( + $$SELECT count(*)::int FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$, + $$VALUES (3)$$, + 'TC-RLS-004: lead sees own and member entries'); + +-- TC-RLS-005: team leads can log time for their members +SELECT lives_ok($$ + INSERT INTO internal.time_trackings (description, date, duration, user_id, project_id, activity_id, aow_id) + SELECT 'rls entry by lead for member', CURRENT_DATE, '15 minutes', + (SELECT user_id FROM auth.users WHERE email = 'rls.member@hellodnk8n.onmicrosoft.com'), + (SELECT project_id FROM internal.projects WHERE name = 'rls project'), + (SELECT activity_id FROM internal.activities WHERE name = 'rls activity'), + (SELECT aow_id FROM internal.areas_of_work WHERE name = 'rls aow') + $$, + 'TC-RLS-005: lead inserts entry for member'); + +RESET ROLE; +SET ROLE "rls.outsider@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-006: outsiders see nothing of others' data +SELECT is_empty( + $$SELECT 1 FROM internal.time_trackings WHERE description LIKE 'rls entry%'$$, + 'TC-RLS-006: outsider sees no rls time trackings'); + +--------------------------------------------------------------- +-- Projects +--------------------------------------------------------------- +-- TC-RLS-010: outsiders cannot see the project +SELECT is_empty( + $$SELECT 1 FROM internal.projects WHERE name = 'rls project'$$, + 'TC-RLS-010: outsider cannot see project'); + +RESET ROLE; +SET ROLE "rls.member@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-011: members see their project +SELECT isnt_empty( + $$SELECT 1 FROM internal.projects WHERE name = 'rls project'$$, + 'TC-RLS-011: member sees own project'); + +RESET ROLE; +SET ROLE "rls.lead@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-013: project leads can update their project +UPDATE internal.projects SET description = 'updated by lead' WHERE name = 'rls project'; +SELECT results_eq( + $$SELECT description FROM internal.projects WHERE name = 'rls project'$$, + $$VALUES ('updated by lead'::text)$$, + 'TC-RLS-013: lead update persisted'); + +-- TC-RLS-014: creating a brand-new project is denied to non-power users +SELECT throws_ok( + $$INSERT INTO internal.projects (name) VALUES ('rls new project')$$, + '42501', NULL, + 'TC-RLS-014: lead cannot create an unrelated new project'); + +RESET ROLE; +SET ROLE "rls.member@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-012: non-lead members should not be able to update the project. +-- KNOWN BUG (expected failure): select_projects_policy is created without +-- FOR SELECT, making it a permissive FOR ALL policy, so its USING clause +-- also authorizes UPDATE/DELETE for every project member. Fixing it means +-- adding FOR SELECT (and re-checking update_leads_projects_policy coverage). +UPDATE internal.projects SET name = 'rls hacked' WHERE name = 'rls project'; +RESET ROLE; +SELECT * FROM todo('select_projects_policy lacks FOR SELECT, members can update projects', 1); +SELECT is_empty( + $$SELECT 1 FROM internal.projects WHERE name = 'rls hacked'$$, + 'TC-RLS-012: member update affected no rows'); + +--------------------------------------------------------------- +-- Reference data (activities / areas of work) +--------------------------------------------------------------- +SET ROLE "rls.member@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-020: reference data is read-only for non-power users +SELECT throws_ok( + $$INSERT INTO internal.areas_of_work (name) VALUES ('rls new aow')$$, + '42501', NULL, + 'TC-RLS-020: basic cannot create areas of work'); +SELECT throws_ok( + $$INSERT INTO internal.activities (name) VALUES ('rls new activity')$$, + '42501', NULL, + 'TC-RLS-020: basic cannot create activities'); + +RESET ROLE; +SET ROLE "rls.power@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-021: power users manage reference data +SELECT lives_ok( + $$INSERT INTO internal.areas_of_work (name) VALUES ('rls power aow')$$, + 'TC-RLS-021: power creates area of work'); + +--------------------------------------------------------------- +-- Users +--------------------------------------------------------------- +RESET ROLE; +SET ROLE "rls.outsider@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-030: unrelated users see only themselves in auth.users +SELECT results_eq( + $$SELECT email FROM auth.users WHERE email LIKE 'rls.%'$$, + $$VALUES ('rls.outsider@hellodnk8n.onmicrosoft.com'::text)$$, + 'TC-RLS-030: outsider sees only self'); + +RESET ROLE; +SET ROLE "rls.member@hellodnk8n.onmicrosoft.com"; + +-- TC-RLS-031: members also see their team lead (and vice versa) +SELECT results_eq( + $$SELECT count(*)::int FROM auth.users WHERE email IN + ('rls.member@hellodnk8n.onmicrosoft.com', 'rls.lead@hellodnk8n.onmicrosoft.com')$$, + $$VALUES (2)$$, + 'TC-RLS-031: member sees self and their lead'); + +--------------------------------------------------------------- +-- FAQs +--------------------------------------------------------------- +-- TC-FAQ-001: only published FAQs are visible to regular users +SELECT results_eq( + $$SELECT question FROM internal.faqs WHERE question LIKE 'rls%'$$, + $$VALUES ('rls published?'::text)$$, + 'TC-FAQ-001: draft FAQ hidden from basic user'); + +-- TC-FAQ-002: regular users cannot create FAQs +SELECT throws_ok( + $$INSERT INTO internal.faqs (question, answer, is_published) VALUES ('x', 'y', TRUE)$$, + '42501', NULL, + 'TC-FAQ-002: basic cannot insert FAQ'); + +RESET ROLE; +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/db/04-role-automation.sql b/tests/db/04-role-automation.sql new file mode 100644 index 0000000..072270d --- /dev/null +++ b/tests/db/04-role-automation.sql @@ -0,0 +1,78 @@ +-- Automatic privilege management: project leads gain/lose `pm`, +-- AoE team supervisors gain/lose `lead`. Test case ids: docs/test-cases.md +BEGIN; + +SELECT plan(8); + +SELECT auth.create_user('auto.lead@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('auto.member@hellodnk8n.onmicrosoft.com'); +SELECT auth.create_user('auto.power@hellodnk8n.onmicrosoft.com'); +SELECT auth.grant_power('auto.power@hellodnk8n.onmicrosoft.com'); +INSERT INTO internal.areas_of_expertise (name) VALUES ('auto aoe'); + +SET ROLE "auto.power@hellodnk8n.onmicrosoft.com"; + +--------------------------------------------------------------- +-- Projects: lead_ids drive the pm role +--------------------------------------------------------------- +-- TC-PROJ-001: creating a project through the api view with a lead grants pm +INSERT INTO api.projects (name, lead_ids, member_ids, activity_ids, aow_ids) +VALUES ('auto project', + (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.lead'), + (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.member'), + '[]'::jsonb, '[]'::jsonb); + +SELECT isnt_empty( + $$SELECT 1 FROM internal.projects WHERE name = 'auto project'$$, + 'TC-PROJ-001: project created through api view'); +SELECT results_eq( + $$SELECT count(*)::int FROM internal.user_project_roles upr + JOIN internal.projects p USING (project_id) WHERE p.name = 'auto project'$$, + $$VALUES (2)$$, + 'TC-PROJ-001: lead and member membership rows created'); +SELECT is_member_of('pm', 'auto.lead@hellodnk8n.onmicrosoft.com', + 'TC-PROJ-001: project lead was granted pm'); + +-- TC-PROJ-002: removing the lead revokes pm (when they lead nothing else) +UPDATE api.projects +SET lead_ids = '[]'::jsonb +WHERE id = (SELECT project_id FROM internal.projects WHERE name = 'auto project'); + +SELECT ok( + NOT pg_has_role('auto.lead@hellodnk8n.onmicrosoft.com', 'pm', 'MEMBER'), + 'TC-PROJ-002: pm revoked after removal as lead'); + +--------------------------------------------------------------- +-- AoE teams: supervising a team drives the lead role +--------------------------------------------------------------- +-- TC-TEAM-010: upserting a team through the api view grants lead to its supervisor +INSERT INTO api.aoe_teams (aoe, lead, member_ids) +SELECT jsonb_build_object('id', aoe_id), + (SELECT jsonb_build_object('id', user_id) FROM auth.users WHERE username = 'auto.lead'), + (SELECT jsonb_build_array(user_id) FROM auth.users WHERE username = 'auto.member') +FROM internal.areas_of_expertise WHERE name = 'auto aoe'; + +SELECT results_eq( + $$SELECT count(*)::int FROM internal.aoe_hierarchies ah + JOIN internal.areas_of_expertise aoe USING (aoe_id) WHERE aoe.name = 'auto aoe'$$, + $$VALUES (1)$$, + 'TC-TEAM-010: hierarchy row created'); +SELECT is_member_of('lead', 'auto.lead@hellodnk8n.onmicrosoft.com', + 'TC-TEAM-010: team supervisor was granted lead'); + +-- TC-TEAM-011: deleting the team (as power) removes it and revokes lead +DELETE FROM api.aoe_teams +WHERE (aoe->>'id')::int = (SELECT aoe_id FROM internal.areas_of_expertise WHERE name = 'auto aoe') + AND (lead->>'id')::int = (SELECT user_id FROM auth.users WHERE username = 'auto.lead'); + +SELECT is_empty( + $$SELECT 1 FROM internal.aoe_hierarchies ah + JOIN internal.areas_of_expertise aoe USING (aoe_id) WHERE aoe.name = 'auto aoe'$$, + 'TC-TEAM-011: hierarchy rows removed by power delete'); +SELECT ok( + NOT pg_has_role('auto.lead@hellodnk8n.onmicrosoft.com', 'lead', 'MEMBER'), + 'TC-TEAM-011: lead revoked after team deletion'); + +RESET ROLE; +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/db/05-security-observations.sql b/tests/db/05-security-observations.sql new file mode 100644 index 0000000..97b66d0 --- /dev/null +++ b/tests/db/05-security-observations.sql @@ -0,0 +1,20 @@ +-- Security posture tests that currently FAIL and point at genuine issues in +-- the application. Per the testing policy (docs/testing.md) they are kept +-- committed but marked TODO (pgTAP's expected-fail), so they don't break the +-- suite; when the underlying issue is fixed, pg_prove reports them as +-- "unexpectedly succeeded" and the todo marker must be removed. +BEGIN; + +SELECT plan(1); + +-- TC-SEC-001: privilege-syncing function should not be executable by basic. +-- internal.aoe_teams_grant_or_revoke_privs is SECURITY DEFINER and can grant +-- or revoke the `lead` role, yet EXECUTE is granted to `basic` (the source +-- marks this "TODO: This is potentially insecure, fix"). +SELECT * FROM todo('aoe_teams_grant_or_revoke_privs is executable by basic; flagged TODO in 013.project_aoe_functions.sql', 1); +SELECT ok( + NOT has_function_privilege('basic', 'internal.aoe_teams_grant_or_revoke_privs(integer)', 'EXECUTE'), + 'TC-SEC-001: basic cannot execute internal.aoe_teams_grant_or_revoke_privs'); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..0946237 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +import { login, POWER_EMAIL } from './helpers'; + +test('TC-E2E-001: power user can log in and out via the debug login', async ({ + page, +}) => { + await login(page, POWER_EMAIL); + + // The main navigation is available + await expect(page.getByRole('menuitem', { name: 'Projects' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Employees' })).toBeVisible(); + + // Log out again + await page.getByLabel('Profile').click(); + await page.getByText('Logout').click(); + await expect( + page.getByRole('button', { name: 'Login as this User' }), + ).toBeVisible({ timeout: 10_000 }); +}); + +test('TC-E2E-002: an unknown user cannot reach the workspace', async ({ page }) => { + await page.goto('/'); + await page + .getByLabel('Enter any username for DEBUG login') + .fill('ghost.user@hellodnk8n.onmicrosoft.com'); + await page.getByRole('button', { name: 'Login as this User' }).click(); + + // Whatever the exact failure UX, the authenticated menu must not appear. + await page.waitForTimeout(3000); + await expect(page.getByRole('menuitem', { name: 'Time Trackings' })).toHaveCount(0); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..c8a203d --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,35 @@ +import { expect, Page } from '@playwright/test'; + +/** The initial power user created by initdb.sh ($POSTGRES_USER_APP_POWER). */ +export const POWER_EMAIL = + process.env.DTRACK_TEST_POWER_EMAIL ?? 'Dummy.User@example.com'; + +/** Unique per test run so created entities never collide with residue. */ +export const runId = Date.now().toString(36); + +/** Log in through the debug login page (VITE_ENVIRONMENT=DEBUG builds). */ +export async function login(page: Page, email: string): Promise { + await page.goto('/'); + await page.getByLabel('Enter any username for DEBUG login').fill(email); + await page.getByRole('button', { name: 'Login as this User' }).click(); + await expect(page.getByRole('menuitem', { name: 'Time Trackings' })).toBeVisible({ + timeout: 15_000, + }); +} + +/** Fill a react-admin (MUI) autocomplete input and pick the matching option. */ +export async function pickOption( + page: Page, + label: string | RegExp, + optionText: string, +): Promise { + const input = page.getByRole('combobox', { name: label }); + await input.click(); + await input.fill(optionText); + await page.getByRole('option', { name: optionText, exact: true }).click(); +} + +/** Wait for react-admin's "Element created" snackbar. */ +export async function expectCreated(page: Page): Promise { + await expect(page.getByText('Element created')).toBeVisible({ timeout: 10_000 }); +} diff --git a/tests/e2e/journey.spec.ts b/tests/e2e/journey.spec.ts new file mode 100644 index 0000000..840f3f7 --- /dev/null +++ b/tests/e2e/journey.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; + +import { expectCreated, login, pickOption, POWER_EMAIL, runId } from './helpers'; + +/** + * TC-E2E-010: the core user journey, end to end. As the power user, build up + * the reference data a time tracking needs (AoW → Activity → Project with + * both linked), then log time against it using the cascading selects, and + * see the entry in the list. + */ +const aowName = `E2E AoW ${runId}`; +const activityName = `E2E Activity ${runId}`; +const projectName = `E2E Project ${runId}`; +const taskDescription = `E2E logged time ${runId}`; + +test.describe.configure({ mode: 'serial' }); + +test('TC-E2E-010a: create an area of work', async ({ page }) => { + await login(page, POWER_EMAIL); + await page.getByRole('menuitem', { name: 'AoWs' }).click(); + await page.getByRole('link', { name: 'Create' }).click(); + await page.getByLabel('Name').fill(aowName); + await page.getByRole('button', { name: 'Save' }).click(); + await expectCreated(page); +}); + +test('TC-E2E-010b: create an activity', async ({ page }) => { + await login(page, POWER_EMAIL); + await page.getByRole('menuitem', { name: 'Activities' }).click(); + await page.getByRole('link', { name: 'Create' }).click(); + await page.getByLabel('Name', { exact: true }).fill(activityName); + await page.getByLabel('Description').fill('Created by the E2E suite'); + await page.getByRole('button', { name: 'Save' }).click(); + await expectCreated(page); +}); + +test('TC-E2E-010c: create a project linking AoW, activity and a manager', async ({ + page, +}) => { + await login(page, POWER_EMAIL); + await page.getByRole('menuitem', { name: 'Projects' }).click(); + await page.getByRole('link', { name: 'Create' }).click(); + await page.getByLabel('Name', { exact: true }).fill(projectName); + await page.getByLabel('Description').fill('Created by the E2E suite'); + await pickOption(page, 'AoWs', aowName); + await pickOption(page, 'Activities', activityName); + await pickOption(page, 'Managers', 'Dummy.User'); + await page.getByRole('button', { name: 'Save' }).click(); + await expectCreated(page); +}); + +test('TC-E2E-010d: log time through the cascading selects', async ({ page }) => { + await login(page, POWER_EMAIL); + await page.getByRole('menuitem', { name: 'Time Trackings' }).click(); + await page.getByRole('link', { name: 'Create' }).click(); + + // Employee Name defaults to the logged-in identity; drive the cascade + await pickOption(page, 'Area of Work', aowName); + await pickOption(page, 'Project', projectName); + await pickOption(page, 'Activity', activityName); + await page.getByLabel('Task Description').fill(taskDescription); + await pickOption(page, 'Duration', '0.25 hrs'); + await page.getByRole('button', { name: 'Save' }).click(); + await expectCreated(page); +}); + +test('TC-E2E-010e: the logged entry appears in the list', async ({ page }) => { + await login(page, POWER_EMAIL); + await page.getByRole('menuitem', { name: 'Time Trackings' }).click(); + await expect(page.getByText(taskDescription)).toBeVisible({ timeout: 15_000 }); +}); + +test('TC-E2E-020: the dashboard renders for a user with data', async ({ page }) => { + await login(page, POWER_EMAIL); + // Dashboard is the default route + await page.goto('/'); + await expect(page.getByRole('tab', { name: 'Chart' })).toBeVisible({ + timeout: 15_000, + }); +}); diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..3b9da41 --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,1730 @@ +{ + "name": "dtrack-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dtrack-tests", + "devDependencies": { + "@playwright/test": "^1.53.0", + "@types/node": "^24.0.0", + "jose": "^6.0.11", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..198c39d --- /dev/null +++ b/tests/package.json @@ -0,0 +1,16 @@ +{ + "name": "dtrack-tests", + "private": true, + "type": "module", + "scripts": { + "test:api": "vitest run api", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.53.0", + "@types/node": "^24.0.0", + "jose": "^6.0.11", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 0000000..932d416 --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + // The suites drive one shared UI/database; keep them sequential. + workers: 1, + use: { + baseURL: process.env.DTRACK_TEST_APP_URI ?? 'http://localhost:5174', + trace: 'retain-on-failure', + }, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], +}); diff --git a/tests/run-api-tests.sh b/tests/run-api-tests.sh new file mode 100755 index 0000000..572e341 --- /dev/null +++ b/tests/run-api-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run the HTTP tests against a running PostgREST (see docs/testing.md). +# Prerequisites: stack up in debug mode and database bootstrapped +# (./initdb.sh && make debug). +set -euo pipefail +cd "$(dirname "$0")" +[ -d node_modules ] || npm ci +npx vitest run api diff --git a/tests/run-db-tests.sh b/tests/run-db-tests.sh new file mode 100755 index 0000000..ba2084c --- /dev/null +++ b/tests/run-db-tests.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Run the pgTAP suite against the running dev/CI postgres container. +# +# pgTAP is installed into the *running container* at test time (packages from +# the PGDG apt repo already configured in the postgres image), so the +# production image stays untouched. The extension lives in the database until +# the volume is recreated; every test file is a single transaction that rolls +# back, so no test data persists. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "--> Ensuring pgTAP and pg_prove are installed in the postgres container" +docker compose exec -T -u root postgres bash -c ' + command -v pg_prove >/dev/null && dpkg -s postgresql-15-pgtap >/dev/null 2>&1 || { + apt-get update -qq && + apt-get install -y -qq postgresql-15-pgtap libtap-parser-sourcehandler-pgtap-perl + }' >/dev/null + +docker compose exec -T postgres psql -q -U postgres -d dtrack \ + -c 'CREATE EXTENSION IF NOT EXISTS pgtap;' + +echo "--> Copying tests into the container and running pg_prove" +docker compose exec -T postgres rm -rf /tmp/dtrack-db-tests +docker compose cp tests/db postgres:/tmp/dtrack-db-tests +docker compose exec -T postgres \ + bash -c 'pg_prove -U postgres -d dtrack /tmp/dtrack-db-tests/*.sql' diff --git a/tests/run-e2e-tests.sh b/tests/run-e2e-tests.sh new file mode 100755 index 0000000..c04d66d --- /dev/null +++ b/tests/run-e2e-tests.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Run the Playwright end-to-end tests against the running debug-mode UI +# (see docs/testing.md). Prerequisites: stack up in debug mode and database +# bootstrapped (./initdb.sh && make debug). +set -euo pipefail +cd "$(dirname "$0")" +[ -d node_modules ] || npm ci +npx playwright install ${CI:+--with-deps} chromium +npx playwright test diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..4dc1336 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["api", "e2e", "*.ts"] +} diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts new file mode 100644 index 0000000..218a959 --- /dev/null +++ b/tests/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['api/**/*.test.ts'], + // API tests hit a shared database; run files sequentially so fixtures + // written by one file can't race another file's assertions. + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 60_000, + }, +});