## Bug
The `OrganizationDataStoresRegistry` singleton in
`apps/webapp/app/services/dataStores/organizationDataStoresRegistryInstance.server.ts`
was constructed with `$replica`. That client was then used by both the
polling read path *and* by `addDataStore` / `updateDataStore` /
`deleteDataStore` (and their backing `SecretStore.setSecret` upserts).
The write methods route through the read replica, which Postgres rejects
with **error code 25006**:
```
Invalid prisma.secretStore.upsert() invocation:
ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError {
code: "25006",
message: "cannot execute INSERT in a read-only transaction",
...
}), transient: false })
```
User-visible symptom: the admin `/admin/data-stores` "Add data store"
form returns a 400 with this error wrapped, so no
`OrganizationDataStore` row can ever be created via the UI.
The read path (`loadFromDatabase` polling + `SecretStore.getSecret`) is
unaffected because `findMany` + secret read are read-only.
## Fix
Change the registry constructor to take both a writer and a replica:
```ts
constructor(writer: PrismaClient, replica: PrismaClient | PrismaReplicaClient)
```
- `loadFromDatabase()` keeps using `_replica` (and its
`SecretStore.getSecret` calls) — these are background cache-fillers, not
on user-latency-sensitive paths.
- `addDataStore` / `updateDataStore` / `deleteDataStore` (and their
`SecretStore.setSecret` / `deleteSecret` calls) now use `_writer`.
`organizationDataStoresRegistryInstance.server.ts` passes `(prisma,
$replica)` from `~/db.server`. Test sites that constructed with
`(prisma)` now pass `(prisma, prisma)` — the testcontainer exposes a
single client, so the writer/replica split collapses to one connection.
## Files
-
`apps/webapp/app/services/dataStores/organizationDataStoresRegistry.server.ts`
— constructor + read/write split
-
`apps/webapp/app/services/dataStores/organizationDataStoresRegistryInstance.server.ts`
— pass `prisma` alongside `$replica`
- `apps/webapp/test/organizationDataStoresRegistry.test.ts` — 14 call
sites bumped
- `apps/webapp/test/clickhouseFactory.test.ts` — 5 call sites bumped
## Test plan
- [x] Existing `organizationDataStoresRegistry.test.ts` +
`clickhouseFactory.test.ts` still pass (constructor sites updated;
behavior unchanged for tests).
- [ ] After deploy to test cloud, retry `/admin/data-stores` "Add data
store" form for the HIPAA org — should now succeed and the row should
appear.
- [ ] Verify the registry's polling reload picks up the new row within
`ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS` (60s default) and the
factory starts routing to the org-scoped instance.
Webapp tests
Three suites live in this directory.
Unit tests — *.test.ts
Run with pnpm test from apps/webapp. Default vitest pickup. No
container setup. Run on every PR via unit-tests-webapp.yml.
Smoke e2e — *.e2e.test.ts
End-to-end auth baseline that proves the route auth plumbing is wired up.
Each file spins up its own webapp + Postgres + Redis container in
beforeAll (~30s startup). Vitest config: vitest.e2e.config.ts. Run on
every PR via e2e-webapp.yml.
cd apps/webapp
pnpm exec vitest --config vitest.e2e.config.ts
Comprehensive auth e2e — *.e2e.full.test.ts
The full RBAC auth matrix — every route family with explicit pass/fail scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for each family's coverage spec.
Architecture: one container reused across the whole suite via
vitest.e2e.full.config.ts's globalSetup. Test files share the server
through getTestServer() from helpers/sharedTestServer.ts. Each test
seeds its own resources so order doesn't matter.
Layout:
| File | Top-level describe | Family subtasks |
|---|---|---|
auth-api.e2e.full.test.ts |
API |
TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
auth-dashboard.e2e.full.test.ts |
Dashboard |
TRI-8742 admin pages |
auth-cross-cutting.e2e.full.test.ts |
Cross-cutting |
TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
Adding a new family: pick the relevant file, add a nested describe
block. Inside, seed your own fixtures via the helpers and hit the shared
server.
describe("Trigger task", () => {
const server = getTestServer();
it("missing Authorization → 401", async () => {
const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
expect(res.status).toBe(401);
});
});
CI: e2e-webapp-auth-full.yml. Triggers on workflow_dispatch,
nightly schedule, and PRs touching auth-relevant paths (route builders,
rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
Run locally:
cd apps/webapp
pnpm exec vitest --config vitest.e2e.full.config.ts