MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-35

1.2.7 Multi-tenant isolation E2E + direct-DB RLS test

Done
Description

Estimate: 13m · Depends on: 1.2.3, 1.2.6

The load-bearing test that validates the Story-level AC's "structurally impossible" claim. Two layers, mirroring the two defense layers from 1.2.3: (1) E2E test via Playwright that signs in as user A in workspace A, tries to access workspace B (via URL manipulation, cookie tampering, forged headers), and asserts every path returns 404; (2) Direct-DB integration test via Vitest that opens a Prisma transaction WITHOUT setting the app.workspace_id GUC and tries to SELECT / UPDATE rows from workspace-scoped tables, asserting RLS denies everything.

Why both layers, not just one: E2E proves the middleware handles every legitimate-looking attack vector; direct-DB proves RLS catches a middleware bug. If only the E2E existed, a future Subtask could disable the middleware (or forget to wrap a new endpoint in withWorkspaceContext) and the test wouldn't catch it — the test would just stop firing in the right code path. The direct-DB test guarantees that even if 100% of the application code were wrong, the database would still refuse to leak.

Why 404 (not 403) for cross-workspace access: 403 leaks the workspace's existence to the attacker ("this workspace exists, you're just not in it"). 404 makes a non-member workspace indistinguishable from a non-existent one. Standard B2B SaaS practice (GitHub, Linear, Notion all return 404 for cross-tenant access).

What you'll do: Create tests/e2e/multi-tenant-isolation.spec.ts (tagged @smoke) that: (a) creates two workspaces (A via sign-up, B via invite to a second user); (b) as user A, attempts to GET /api/workspaces/{B.id}/invites, PATCH workspace B, leave workspace B, delete workspace B — assert 404 on all; (c) attempts to read workspace B's data by forging the workspace_id cookie value — assert the middleware re-validates membership and returns 404. Create tests/multi-tenant-rls.test.ts (Vitest) that opens db.$transaction without withWorkspaceContext and asserts SELECT returns zero rows from workspace and workspace_membership tables; that an INSERT into workspace_membership for a workspace the GUC doesn't grant access to fails with a Postgres RLS error; that an UPDATE to a workspace not matching the GUC affects zero rows. Run the cascade-delete portion of the test by creating a workspace with a membership, deleting the workspace, asserting the membership row is also gone.

Acceptance criteria

  • Playwright spec tests/e2e/multi-tenant-isolation.spec.ts exists and is tagged @smoke; passes locally and in CI.
  • E2E covers: cross-workspace GET returns 404 (not 403, not 200); cross-workspace mutation (PATCH, DELETE) returns 404; forged workspace_id cookie pointing at a workspace the user isn't a member of returns 404 (middleware re-validates).
  • Vitest spec tests/multi-tenant-rls.test.ts exists; passes locally and in CI.
  • RLS test covers: SELECT without GUC returns zero rows from workspace + workspace_membership; SELECT with GUC matching workspace A returns workspace A's rows only (no workspace B); INSERT into workspace_membership for a workspace not matching the GUC fails with RLS denial; UPDATE on a workspace not matching the GUC affects zero rows.
  • Cascade-delete test verifies: deleting a workspace deletes all its memberships in the same transaction; deleting a user deletes all their memberships (memberships' user FK is also CASCADE).
  • All quality gates green; total test count grows by ~15-20 (E2E + Vitest combined).

Context refs

  • tests/e2e/auth-credentials.spec.ts + auth-google.spec.ts from 1.1.7 — the E2E patterns for sign-up + sign-in + DB assertion
  • tests/password-reset.test.ts from 1.1.6 — the Vitest pattern for testing Better-Auth handler routes directly (this Subtask uses the same approach for the workspace API routes)
  • lib/workspaces/{context,middleware}.ts from 1.2.3 — the gates being tested
  • Postgres docs (fetched at prompt-gen time): RLS error codes (specifically: 42501 insufficient_privilege for denied mutations, 0 rows affected for denied reads — both are valid RLS-denial signals depending on the query)
  • OWASP IDOR (Insecure Direct Object Reference) reference (fetched at prompt-gen time) — the threat model this test validates against
Status
Done
Type
Sub-task