test(isolation): per-user cross-isolation suite across modules #196

Merged
David merged 1 commit from feat/pms-264-per-user-isolation-suite into main 2026-06-13 16:01:51 +02:00
Owner

Add tests/per_user_isolation.rs, the PMS-264 regression suite (Step 9 of 9 of the PMS-255 per-user-isolation epic). It stands up two users in two distinct personal tenants (each provisioned via TenantService::ensure_personal_tenant, so each carries the full seeded lookup set) and drives the real HTTP API as a client would.

cross_user_read_and_write_denied_across_modules (AC #1) has user A create a record in every module with real handlers (contacts companies + contacts, tickets, time_tracking time entries). User B then gets 404 on every single-record GET of A's ids and B's list never contains A's rows (read denial). For writes, B aims an UPDATE then a DELETE at A's row id; handlers differ on the surface code for a cross-tenant id (a tenant-scoped write matches no row, so some 404 and some report a no-op 200), so the suite asserts the isolation EFFECT instead: A's row stays present and keeps its original field value, never B's "hijacked" sentinel. That is the request-path face of the WITH CHECK guarantee (PMS-255.2 / PMS-257); the DB-engine face (SQLSTATE 42501 on a GUC-mismatched write) is pinned in tests/rls_isolation.rs.

cross_user_tenant_endpoint_denied (AC #1, tenants module) proves B cannot read A's tenant row (403 authz guard). dashboard_aggregate_is_caller_scoped (AC #2) proves reports::dashboard counts only the caller's own tickets across the two tenants.

Actors are ordinary technician users, not super_admin: super_admin is the cross-tenant system role (get_tenant lets it read any tenant) and would mask the isolation under test. The module-gated time_tracking / reports endpoints check module enablement (copied into each personal tenant), not role, so a technician still reaches every module. Each test tenant is pre-marked settings.demo_seeded = true to neuter the racy first-visit demo-seed middleware (PMS-157) that would otherwise inject nondeterministic rows; demo seeding keeps its own coverage in tests/seed_demo.rs.

AC #3 (RLS fail-closed regression) and AC #4 (per-user lookup seeding / isolation) are already pinned by tests/rls_isolation.rs and tests/personal_tenant_seed.rs; the suite docstring cross-references both so the PMS-264 coverage reads as one set.

#PMS-264

Add `tests/per_user_isolation.rs`, the PMS-264 regression suite (Step 9 of 9 of the PMS-255 per-user-isolation epic). It stands up two users in two distinct personal tenants (each provisioned via `TenantService::ensure_personal_tenant`, so each carries the full seeded lookup set) and drives the real HTTP API as a client would. `cross_user_read_and_write_denied_across_modules` (AC #1) has user A create a record in every module with real handlers (contacts companies + contacts, tickets, time_tracking time entries). User B then gets 404 on every single-record GET of A's ids and B's list never contains A's rows (read denial). For writes, B aims an UPDATE then a DELETE at A's row id; handlers differ on the surface code for a cross-tenant id (a tenant-scoped write matches no row, so some 404 and some report a no-op 200), so the suite asserts the isolation EFFECT instead: A's row stays present and keeps its original field value, never B's "hijacked" sentinel. That is the request-path face of the WITH CHECK guarantee (PMS-255.2 / PMS-257); the DB-engine face (SQLSTATE 42501 on a GUC-mismatched write) is pinned in `tests/rls_isolation.rs`. `cross_user_tenant_endpoint_denied` (AC #1, tenants module) proves B cannot read A's tenant row (403 authz guard). `dashboard_aggregate_is_caller_scoped` (AC #2) proves `reports::dashboard` counts only the caller's own tickets across the two tenants. Actors are ordinary `technician` users, not super_admin: super_admin is the cross-tenant system role (`get_tenant` lets it read any tenant) and would mask the isolation under test. The module-gated time_tracking / reports endpoints check module enablement (copied into each personal tenant), not role, so a technician still reaches every module. Each test tenant is pre-marked `settings.demo_seeded = true` to neuter the racy first-visit demo-seed middleware (PMS-157) that would otherwise inject nondeterministic rows; demo seeding keeps its own coverage in `tests/seed_demo.rs`. AC #3 (RLS fail-closed regression) and AC #4 (per-user lookup seeding / isolation) are already pinned by `tests/rls_isolation.rs` and `tests/personal_tenant_seed.rs`; the suite docstring cross-references both so the PMS-264 coverage reads as one set. #PMS-264
test(isolation): per-user cross-isolation suite across modules
Some checks failed
E2E (staging) / Playwright against staging (pull_request) Failing after 16s
Check / fmt + clippy + compile + tests (pull_request) Successful in 1m28s
Create release / Create release from merged PR (pull_request) Has been skipped
f71a7c6567
Add `tests/per_user_isolation.rs`, the PMS-264 regression suite (Step 9 of 9 of the PMS-255 per-user-isolation epic). It stands up two users in two distinct personal tenants (each provisioned via `TenantService::ensure_personal_tenant`, so each carries the full seeded lookup set) and drives the real HTTP API as a client would.

`cross_user_read_and_write_denied_across_modules` (AC #1) has user A create a record in every module with real handlers (contacts companies + contacts, tickets, time_tracking time entries). User B then gets 404 on every single-record GET of A's ids and B's list never contains A's rows (read denial). For writes, B aims an UPDATE then a DELETE at A's row id; handlers differ on the surface code for a cross-tenant id (a tenant-scoped write matches no row, so some 404 and some report a no-op 200), so the suite asserts the isolation EFFECT instead: A's row stays present and keeps its original field value, never B's "hijacked" sentinel. That is the request-path face of the WITH CHECK guarantee (PMS-255.2 / PMS-257); the DB-engine face (SQLSTATE 42501 on a GUC-mismatched write) is pinned in `tests/rls_isolation.rs`.

`cross_user_tenant_endpoint_denied` (AC #1, tenants module) proves B cannot read A's tenant row (403 authz guard). `dashboard_aggregate_is_caller_scoped` (AC #2) proves `reports::dashboard` counts only the caller's own tickets across the two tenants.

Actors are ordinary `technician` users, not super_admin: super_admin is the cross-tenant system role (`get_tenant` lets it read any tenant) and would mask the isolation under test. The module-gated time_tracking / reports endpoints check module enablement (copied into each personal tenant), not role, so a technician still reaches every module. Each test tenant is pre-marked `settings.demo_seeded = true` to neuter the racy first-visit demo-seed middleware (PMS-157) that would otherwise inject nondeterministic rows; demo seeding keeps its own coverage in `tests/seed_demo.rs`.

AC #3 (RLS fail-closed regression) and AC #4 (per-user lookup seeding / isolation) are already pinned by `tests/rls_isolation.rs` and `tests/personal_tenant_seed.rs`; the suite docstring cross-references both so the PMS-264 coverage reads as one set.

#PMS-264
David merged commit 2e62ad8a17 into main 2026-06-13 16:01:51 +02:00
David deleted branch feat/pms-264-per-user-isolation-suite 2026-06-13 16:01:52 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!196
No description provided.