From 206c728c9cc60c8d4db6cb7f4ffd9d5c6dd2cc8f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 13:30:08 +1000 Subject: [PATCH 01/11] test(stack): move the v3 live suites onto the integration harness; drop the LIVE_* gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks #9 + #10. The v3 live (network/DB-touching) suites gated themselves with `describeLive*` and inner `USER_JWT` soft-skips, so a missing credential turned them into silent whole-suite skips on a green job. `live-coverage-guard.test.ts` existed only to paper over that. This removes the gate mechanism entirely: every live suite moves under `packages/stack/integration/`, where the harness THROWS on missing configuration (global-setup + `requireIntegrationEnv`) and the no-skips reporter fails the run on any skip. Moved and de-gated (describeLive* → describe, `process.env.DATABASE_URL` → `databaseUrl()`, per-suite `installEqlV3IfNeeded` dropped — global-setup installs EQL v3 once per run): - crypto-only → integration/shared/: matrix-live → matrix-crypto, matrix-bulk, matrix-keyset, schema-v3-client - SQL/pg → integration/shared/: matrix-live-pg → matrix-sql, schema-v3-pg → schema-pg - Drizzle → integration/drizzle-v3/: operators-null-live-pg → null-persistence - Supabase pg-only → integration/supabase/: supabase-v3-grants-pg → grants, supabase-v3-introspect-pg → introspect Deleted `helpers/live-gate.ts`, `helpers/eql-v3.ts` (orphaned), and `live-coverage-guard.test.ts`. Added `unwrapResult` and a `userjwt` requirement to `@cipherstash/test-kit`. Two fixes surfaced by the move: - schema-pg's functional-index assertion hardcoded `schemaname = 'public'`, but an unqualified CREATE TABLE lands in the owning role's schema (`cipherstash`) under a `"$user", public` search_path — it now matches by table + index name. - matrix-sql used all 39 domains including `_ord_ore`; those cannot hold data on non-superuser `supabase/postgres` (the suite runs on both variants), so it now filters to `isCovered`, matching relational.integration.test.ts. ORE SQL coverage stays a superuser-only follow-up. USER_JWT-dependent suites (matrix-identity, lock-context) move to integration/identity/ and require `userjwt` (throw, not skip), but that directory is deliberately NOT in the CI suite globs yet: the `USER_JWT` repo secret is unprovisioned (#530). Both workflows document the exclusion so the gap is visible. Not done here: extending no-skip enforcement to the UNIT config. The unit tree still carries ~15 pre-existing v2 USER_JWT soft-skips plus `supabase.test.ts`'s `SUPABASE_ENABLED` gate — out of the v3 scope and (for USER_JWT) blocked on the same secret. Tracked as a follow-up. Verified against live Docker + a local CipherStash profile: 336 tests green on plain Postgres, the shared+supabase suites green on supabase/postgres, the identity suites fail loudly (not skip) without USER_JWT, and the unit suite is 1588 green / 5 skipped (down from 16), the one failure being the pre-existing searchable-json-pg DB-env requirement. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .github/workflows/integration-drizzle.yml | 8 +- .github/workflows/integration-supabase.yml | 10 +- packages/stack/__tests__/helpers/eql-v3.ts | 72 ---------- packages/stack/__tests__/helpers/live-gate.ts | 58 -------- .../__tests__/live-coverage-guard.test.ts | 132 ------------------ .../__tests__/supabase-schema-builder.test.ts | 2 +- .../null-persistence.integration.test.ts} | 18 +-- .../lock-context.integration.test.ts} | 56 +++----- .../matrix-identity.integration.test.ts} | 13 +- .../shared/matrix-bulk.integration.test.ts} | 6 +- .../shared/matrix-crypto.integration.test.ts} | 12 +- .../shared/matrix-keyset.integration.test.ts} | 7 +- .../shared/matrix-sql.integration.test.ts} | 42 +++--- .../shared/schema-pg.integration.test.ts} | 31 ++-- .../schema-v3-client.integration.test.ts} | 6 +- .../supabase/grants.integration.test.ts} | 21 ++- .../supabase/introspect.integration.test.ts} | 29 ++-- packages/test-kit/src/env.ts | 9 +- packages/test-kit/src/index.ts | 1 + packages/test-kit/src/results.ts | 16 +++ 20 files changed, 130 insertions(+), 419 deletions(-) delete mode 100644 packages/stack/__tests__/helpers/eql-v3.ts delete mode 100644 packages/stack/__tests__/helpers/live-gate.ts delete mode 100644 packages/stack/__tests__/live-coverage-guard.test.ts rename packages/stack/{__tests__/drizzle-v3/operators-null-live-pg.test.ts => integration/drizzle-v3/null-persistence.integration.test.ts} (90%) rename packages/stack/{__tests__/drizzle-v3/operators-lock-context-live-pg.test.ts => integration/identity/lock-context.integration.test.ts} (88%) rename packages/stack/{__tests__/v3-matrix/matrix-identity-live.test.ts => integration/identity/matrix-identity.integration.test.ts} (85%) rename packages/stack/{__tests__/v3-matrix/matrix-bulk.test.ts => integration/shared/matrix-bulk.integration.test.ts} (88%) rename packages/stack/{__tests__/v3-matrix/matrix-live.test.ts => integration/shared/matrix-crypto.integration.test.ts} (94%) rename packages/stack/{__tests__/v3-matrix/matrix-keyset.test.ts => integration/shared/matrix-keyset.integration.test.ts} (88%) rename packages/stack/{__tests__/v3-matrix/matrix-live-pg.test.ts => integration/shared/matrix-sql.integration.test.ts} (96%) rename packages/stack/{__tests__/schema-v3-pg.test.ts => integration/shared/schema-pg.integration.test.ts} (95%) rename packages/stack/{__tests__/schema-v3-client.test.ts => integration/shared/schema-v3-client.integration.test.ts} (98%) rename packages/stack/{__tests__/supabase-v3-grants-pg.test.ts => integration/supabase/grants.integration.test.ts} (91%) rename packages/stack/{__tests__/supabase-v3-introspect-pg.test.ts => integration/supabase/introspect.integration.test.ts} (83%) create mode 100644 packages/test-kit/src/results.ts diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index acb2e0ccd..6ef27bb9f 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -83,8 +83,12 @@ jobs: CS_IT_DB_VARIANT: ${{ matrix.db }} # Scoped by directory, never by named file, so a renamed suite cannot # silently drop from CI. `integration/shared/` holds the adapter-agnostic - # suites (harness, bloom, ope-term); the Supabase adapter suites are not - # run here — they have their own job. + # suites (harness, bloom, ope-term, the crypto/SQL matrices); the Supabase + # adapter suites are not run here — they have their own job. + # + # `integration/identity/` is DELIBERATELY absent: those suites require a + # `USER_JWT` that CI does not yet provision (#530). They throw rather than + # skip when run without it, and join this list once the secret lands. CS_IT_SUITE: >- integration/shared/**/*.integration.test.ts, integration/drizzle-v3/**/*.integration.test.ts diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 894b41165..9b24e2f49 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -66,9 +66,13 @@ jobs: CS_IT_DB_VARIANT: ${{ matrix.db }} # Scoped by directory, never by named file, so a renamed suite cannot # silently drop from CI. `integration/shared/` holds the adapter-agnostic - # suites (harness, bloom, and the ope-term tripwire for THIS job's - # `col->op` ordering path); the Drizzle suites talk to plain Postgres and - # get their own job. + # suites (harness, bloom, the ope-term tripwire for THIS job's `col->op` + # ordering path, and the crypto/SQL matrices); the Drizzle suites talk to + # plain Postgres and get their own job. + # + # `integration/identity/` is DELIBERATELY absent: those suites require a + # `USER_JWT` that CI does not yet provision (#530). They throw rather than + # skip when run without it, and join this list once the secret lands. CS_IT_SUITE: >- integration/shared/**/*.integration.test.ts, integration/supabase/**/*.integration.test.ts diff --git a/packages/stack/__tests__/helpers/eql-v3.ts b/packages/stack/__tests__/helpers/eql-v3.ts deleted file mode 100644 index 512741ba8..000000000 --- a/packages/stack/__tests__/helpers/eql-v3.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { readInstallSql, releaseManifest } from '@cipherstash/eql/sql' -import type postgres from 'postgres' - -const EQL_V3_ADVISORY_LOCK_ID = 3_733_003 - -// Staleness is decided by asking the database which EQL release it is running -// and comparing that to the release the pinned @cipherstash/eql ships. Earlier -// revisions probed for a sentinel type (public.eql_v3_text_search, then -// public.eql_v3_timestamp) that was hand-picked to exist only in the newest bundle. -// Every such sentinel decays: the next bundle keeps the type, the check starts -// reporting "current" against a stale install, and the suite silently exercises -// the wrong SQL. eql_v3.version() carries the release identity itself, so this -// needs no maintenance when the pin moves. -// -// The probe has to be its own statement. Postgres resolves function references -// while parsing a statement, before any branch of it runs, so guarding the call -// with CASE WHEN to_regprocedure(...) IS NULL in the same statement still raises -// "schema eql_v3 does not exist" against a database with no EQL v3 — the guard -// only ever succeeds when it is not needed. to_regprocedure() takes the name as -// a string, so on its own it yields NULL rather than raising, and a database -// with no EQL v3 — or with a bundle predating eql_v3.version() — reads as stale -// and gets installed. -async function readEqlV3Version(sql: postgres.Sql): Promise { - const [probe] = await sql<{ present: boolean }[]>` - SELECT to_regprocedure('eql_v3.version()') IS NOT NULL AS present - ` - if (!probe?.present) return null - - const [row] = await sql< - { version: string }[] - >`SELECT eql_v3.version() AS version` - return row?.version ?? null -} - -/** - * Install the EQL v3 SQL bundle shipped by @cipherstash/eql only when the - * target database is not already running that exact release. - * - * The bundle starts with DROP SCHEMA IF EXISTS eql_v3 CASCADE, so callers must - * never run it unconditionally against a shared test database. - */ -export async function installEqlV3IfNeeded(sql: postgres.Sql): Promise { - // Advisory locks are session-scoped, so the whole check/install/unlock flow - // must run on a single reserved connection. Issuing the lock/unlock via the - // pool can land them on different pooled backends — allowing an install race - // and unlocking a backend that never held the lock. - const reserved = await sql.reserve() - - try { - await reserved`SELECT pg_advisory_lock(${EQL_V3_ADVISORY_LOCK_ID})` - - try { - if ((await readEqlV3Version(reserved)) === releaseManifest.eqlVersion) - return - - // Sent as one multi-statement string: the bundle is ~43k lines and a - // statement-at-a-time client would pay a round-trip per CREATE FUNCTION. - await reserved.unsafe(readInstallSql()) - - const installed = await readEqlV3Version(reserved) - if (installed !== releaseManifest.eqlVersion) { - throw new Error( - `EQL v3 installation did not yield the expected release: wanted ${releaseManifest.eqlVersion}, got ${installed ?? 'no eql_v3.version()'}`, - ) - } - } finally { - await reserved`SELECT pg_advisory_unlock(${EQL_V3_ADVISORY_LOCK_ID})` - } - } finally { - reserved.release() - } -} diff --git a/packages/stack/__tests__/helpers/live-gate.ts b/packages/stack/__tests__/helpers/live-gate.ts deleted file mode 100644 index 3d972868a..000000000 --- a/packages/stack/__tests__/helpers/live-gate.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe } from 'vitest' - -/** - * Shared env gates for the live (network-touching) suites. Previously each - * live suite re-declared these; one definition keeps the credential list — - * and therefore what "live" means — from drifting between files. - * - * Callers must `import 'dotenv/config'` BEFORE importing this module (all - * live suites already do, as their first import) so the env is populated - * when these are evaluated. - */ - -/** True when live CipherStash (ZeroKMS/CTS) credentials are configured. */ -export const LIVE_CIPHERSTASH_ENABLED = Boolean( - process.env.CS_WORKSPACE_CRN && - process.env.CS_CLIENT_ID && - process.env.CS_CLIENT_KEY && - process.env.CS_CLIENT_ACCESS_KEY, -) - -/** True when live credentials AND a Postgres `DATABASE_URL` are configured. */ -export const LIVE_EQL_V3_PG_ENABLED = Boolean( - process.env.DATABASE_URL && LIVE_CIPHERSTASH_ENABLED, -) - -/** - * True when live credentials AND a `USER_JWT` are configured. The identity / - * lock-context live suites additionally require a `USER_JWT` to bind keys to an - * end-user identity, and SOFT-SKIP (inline `if (!userJwt) return`) when it is - * absent — so a missing/rotated `USER_JWT` lets them skip green in CI. This - * flag lets the live-coverage guard assert that path is actually exercised. - */ -export const LIVE_LOCK_CONTEXT_ENABLED = Boolean( - process.env.USER_JWT && LIVE_CIPHERSTASH_ENABLED, -) - -export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip - -export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip - -/** - * True when only a Postgres `DATABASE_URL` is configured (no CipherStash creds - * needed — introspection reads the schema, it does not encrypt). - * - * Like the flags above, a false value turns its suites into `describe.skip`, - * which in CI would be a silent whole-suite skip on a green job. That hole is - * closed by `../live-coverage-guard.test.ts`, which asserts THIS flag in CI. - * Any new gate flag added here must be asserted there too. - */ -export const LIVE_PG_ENABLED = Boolean(process.env.DATABASE_URL) - -export const describeLivePgOnly = LIVE_PG_ENABLED ? describe : describe.skip - -// The PostgREST gate (`LIVE_SUPABASE_PGREST_ENABLED` / `describeLiveSupabasePgrest`) -// is gone. Its only consumer, `supabase-v3-pgrest-live.test.ts`, moved to -// `integration/supabase/wire.integration.test.ts`, which THROWS on missing -// configuration instead of skipping — so no gate, and nothing for the -// coverage guard to assert. diff --git a/packages/stack/__tests__/live-coverage-guard.test.ts b/packages/stack/__tests__/live-coverage-guard.test.ts deleted file mode 100644 index 882310eb9..000000000 --- a/packages/stack/__tests__/live-coverage-guard.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Live-coverage guard — a meta-test that is DELIBERATELY NOT wrapped in any - * `describeLive*` gate, so it always runs (in CI and locally). - * - * ## Why this exists - * Every live (network/DB-touching) suite gates itself on the flags exported by - * `./helpers/live-gate`: - * - `LIVE_CIPHERSTASH_ENABLED` — all four CS_* creds present - * - `LIVE_EQL_V3_PG_ENABLED` — the above AND `DATABASE_URL` present - * When a flag is false the suite becomes `describe.skip`. That is correct - * locally, but in CI it means a rotated/cleared secret (or a missing - * `DATABASE_URL`) makes the ENTIRE live matrix silently skip while the job - * still goes GREEN — zero live coverage, no signal. - * - * The `require-cs-secrets` CI action catches missing CS_* secrets, but it - * (a) does NOT check `DATABASE_URL` (the pg-gate input), and (b) only proves - * the ENV is set on the runner, not that the suites actually consumed it and - * ran. This meta-test closes both gaps by asserting the very flags the live - * suites branch on. Because the suites gate on these EXACT flags, proving the - * flags are `true` in CI transitively guarantees the live suites — and their - * `beforeAll` setup — execute rather than skip. A silent whole-matrix skip - * becomes a loud, single-line failure. - * - * ## CI-only enforcement - * Enforcement is scoped to `process.env.CI` (GitHub Actions always sets - * `CI=true`). Locally, with no creds, this test is a no-op so ordinary dev - * runs still pass. See `.github/workflows/tests.yml` — the `run-tests` job runs - * `pnpm run test` on a runner where GitHub injects `CI=true`, and the CS_* - * creds + `DATABASE_URL` are written into `packages/stack/.env` (loaded here - * via `dotenv/config`, exactly as the live suites load them). - * - * ## Import order matters - * `import 'dotenv/config'` MUST come first: in CI the creds live in - * `packages/stack/.env`, and `live-gate` evaluates the flags at module-load - * time. Loading dotenv before importing the gate mirrors what every live suite - * does and ensures the flags reflect the real environment. - * - * ## Layer 2 (execution census) — intentionally NOT added - * A stronger check would assert each critical live file executed its expected - * number of tests with zero skips (e.g. via `--reporter=json` + a script, or a - * vitest `onFinished` hook). It is omitted on purpose: - * - The live suites gate on the SAME flags asserted here, so this Layer-1 - * check already guarantees they run — a census would only re-detect the - * same "did the matrix run" failure it is meant to catch. - * - A per-file count census needs hardcoded expected totals that drift every - * time a domain/case is added, turning a safety net into maintenance debt. - * - It would add an extra CI step / reporter wiring for marginal signal. - * If a future regression is ever a *partial* skip (individual `it.skip` inside - * an enabled suite — a different failure mode than the silent whole-matrix skip - * this guards), extend here with a `--reporter=json` post-run script asserting - * `numPendingTests === 0` for the critical files, rather than hardcoding counts. - */ -import 'dotenv/config' -import { describe, expect, it } from 'vitest' -import { - LIVE_CIPHERSTASH_ENABLED, - LIVE_EQL_V3_PG_ENABLED, - LIVE_LOCK_CONTEXT_ENABLED, - LIVE_PG_ENABLED, -} from './helpers/live-gate' - -// GitHub Actions always sets CI=true; treat any truthy CI as "must run live". -const IN_CI = Boolean(process.env.CI) - -describe('live-coverage guard', () => { - it.runIf(IN_CI)( - 'CI must have CipherStash creds so the live suites do not silently skip', - () => { - expect( - LIVE_CIPHERSTASH_ENABLED, - 'CI must run the live suites — CS_* creds missing (CS_WORKSPACE_CRN / ' + - 'CS_CLIENT_ID / CS_CLIENT_KEY / CS_CLIENT_ACCESS_KEY) → every ' + - '`describeLive` suite becomes describe.skip and the live matrix ' + - 'silently skips while CI stays green.', - ).toBe(true) - }, - ) - - it.runIf(IN_CI)( - 'CI must have DATABASE_URL so the live pg suites do not silently skip', - () => { - expect( - LIVE_EQL_V3_PG_ENABLED, - 'CI must run the live Postgres suites — `LIVE_EQL_V3_PG_ENABLED` is ' + - 'false. This needs the CS_* creds AND a `DATABASE_URL`; a missing ' + - 'DATABASE_URL is the hole the require-cs-secrets action does not ' + - 'cover, and makes every `describeLivePg` suite silently skip.', - ).toBe(true) - }, - ) - - // DEFERRED (follow-up): the CI `USER_JWT` secret is not yet provisioned, so - // enforcing this now would fail CI. Skipped until the secret exists — flip - // back to `it.runIf(IN_CI)` at that point. It still documents the real gap: - // the identity / lock-context live suites soft-skip on a missing USER_JWT, so - // once the secret lands this guard turns a silent whole-suite skip into a - // loud failure (as the CS_*/DATABASE_URL guards already do). - it.skip('CI must have USER_JWT so the lock-context live suites do not silently skip', () => { - expect( - LIVE_LOCK_CONTEXT_ENABLED, - 'CI must run the live lock-context / identity suites — ' + - '`LIVE_LOCK_CONTEXT_ENABLED` is false. This needs the CS_* creds AND ' + - 'a `USER_JWT`; the identity/lock-context suites (e.g. ' + - 'lock-context.test.ts, protect-ops.test.ts, ' + - 'operators-lock-context-live-pg.test.ts) SOFT-SKIP when USER_JWT is ' + - 'absent, so a missing/rotated USER_JWT lets them skip green with no ' + - 'signal — the exact failure mode this guard exists to prevent.', - ).toBe(true) - }) - - it.runIf(IN_CI)( - 'CI must have DATABASE_URL so the pg-only suites do not silently skip', - () => { - expect( - LIVE_PG_ENABLED, - 'CI must run the DB-only suites — `LIVE_PG_ENABLED` is false. This ' + - 'needs only `DATABASE_URL` (no CS_* creds: introspection reads the ' + - 'schema, it does not encrypt), and `.github/workflows/tests.yml` ' + - 'writes it as a literal into packages/stack/.env alongside a Postgres ' + - 'service — so a false value here is a broken workflow, never a valid ' + - 'configuration. It makes every `describeLivePgOnly` suite (e.g. ' + - 'supabase-v3-introspect-pg) silently skip while CI stays green.', - ).toBe(true) - }, - ) - - // Local dev with no creds: nothing to assert. Keep at least one always-run - // assertion so the file is never reported as fully empty/pending. - it('is always collected (guard file runs outside every live gate)', () => { - expect(typeof IN_CI).toBe('boolean') - }) -}) diff --git a/packages/stack/__tests__/supabase-schema-builder.test.ts b/packages/stack/__tests__/supabase-schema-builder.test.ts index 27ce52180..725023da4 100644 --- a/packages/stack/__tests__/supabase-schema-builder.test.ts +++ b/packages/stack/__tests__/supabase-schema-builder.test.ts @@ -200,7 +200,7 @@ describe('mergeDeclaredTables', () => { // The three-way classification (plaintext / modelled / unmodelled) moved into // `UNMODELLED_COLUMNS_QUERY`'s predicate, so it is proven against live Postgres -// in `supabase-v3-introspect-pg.test.ts`, not here. What remains client-side is +// in `integration/supabase/introspect.integration.test.ts`, not here. What remains client-side is // the grouping — and, load-bearing, the fact that `synthesizeTables` treats an // unmodelled column as plaintext (covered above): that is exactly why the // `from()` guard must be unconditional. diff --git a/packages/stack/__tests__/drizzle-v3/operators-null-live-pg.test.ts b/packages/stack/integration/drizzle-v3/null-persistence.integration.test.ts similarity index 90% rename from packages/stack/__tests__/drizzle-v3/operators-null-live-pg.test.ts rename to packages/stack/integration/drizzle-v3/null-persistence.integration.test.ts index 325f809f3..84e96158e 100644 --- a/packages/stack/__tests__/drizzle-v3/operators-null-live-pg.test.ts +++ b/packages/stack/integration/drizzle-v3/null-persistence.integration.test.ts @@ -12,26 +12,20 @@ * the NULL row, `isNotNull` selects the present row, the NULL cell reads back * as SQL NULL, and the present cell still decrypts to its plaintext. */ -import 'dotenv/config' +import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3 } from '@/encryption/v3' import { createEncryptionOperatorsV3, extractEncryptionSchemaV3, } from '@/eql/v3/drizzle' import { makeEqlV3Column } from '@/eql/v3/drizzle/column' -import { installEqlV3IfNeeded } from '../helpers/eql-v3' -import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate' -import { V3_MATRIX } from '../v3-matrix/catalog' -const url = process.env.DATABASE_URL -const sqlClient = LIVE_EQL_V3_PG_ENABLED - ? postgres(url as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sqlClient = postgres(databaseUrl(), { prepare: false }) const TABLE_NAME = 'protect_ci_v3_drizzle_nullable' const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -111,8 +105,7 @@ async function selectRowKeys(condition: SQL): Promise { } beforeAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - await installEqlV3IfNeeded(sqlClient) + // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [schema] }) ops = createEncryptionOperatorsV3(client) db = drizzle({ client: sqlClient }) @@ -142,12 +135,11 @@ beforeAll(async () => { }, 120000) afterAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` await sqlClient.end() }, 30000) -describeLivePg('v3 drizzle NULL persistence across tiers (live pg)', () => { +describe('v3 drizzle NULL persistence across tiers (live pg)', () => { it.each(TIERS)('$domain isNull selects the NULL row', async (tier) => { expect(await selectRowKeys(ops.isNull(columnFor(tier.key)))).toEqual([ ROW_A, diff --git a/packages/stack/__tests__/drizzle-v3/operators-lock-context-live-pg.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts similarity index 88% rename from packages/stack/__tests__/drizzle-v3/operators-lock-context-live-pg.test.ts rename to packages/stack/integration/identity/lock-context.integration.test.ts index 16c36b6ce..81a5c6269 100644 --- a/packages/stack/__tests__/drizzle-v3/operators-lock-context-live-pg.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -14,10 +14,13 @@ * `OidcFederationStrategy` and binds the key to the `sub` claim with a plain * `.withLockContext({ identityClaim })`. * - * Gated twice: `describeLivePg` (needs `DATABASE_URL` + CS creds) and an inner - * `USER_JWT` guard (soft-skip, matching the existing identity/lock-context - * suites). Whether the searchable index terms are themselves identity-bound is - * decided inside `@cipherstash/protect-ffi`, not this repo. + * Lives under `integration/identity/`: like every integration suite it THROWS + * rather than skips when unconfigured, and additionally requires `USER_JWT` + * (asserted in `beforeAll` via `requireIntegrationEnv(['userjwt'])`). That + * directory is not yet in any CI job's suite globs — the `USER_JWT` repo secret + * is unprovisioned (#530) — so these run locally with a token today and join CI + * once the secret lands. Whether the searchable index terms are themselves + * identity-bound is decided inside `@cipherstash/protect-ffi`, not this repo. * * We assert the symmetric behaviour (same lock context on seed + query matches * and decrypts) AND the negative path — an identity-bound row must not match a @@ -29,27 +32,25 @@ * second JWT with a different `sub` — a lock context only names the claim while * ZeroKMS resolves its value from the authenticating token. Tracked separately. */ -import 'dotenv/config' import { OidcFederationStrategy } from '@cipherstash/auth' +import { + databaseUrl, + requireIntegrationEnv, + V3_MATRIX, +} from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3 } from '@/encryption/v3' import { createEncryptionOperatorsV3, extractEncryptionSchemaV3, } from '@/eql/v3/drizzle' import { makeEqlV3Column } from '@/eql/v3/drizzle/column' -import { installEqlV3IfNeeded } from '../helpers/eql-v3' -import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate' -import { V3_MATRIX } from '../v3-matrix/catalog' -const url = process.env.DATABASE_URL -const sqlClient = LIVE_EQL_V3_PG_ENABLED - ? postgres(url as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sqlClient = postgres(databaseUrl(), { prepare: false }) const TABLE_NAME = 'protect_ci_v3_drizzle_lock_context' const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -148,15 +149,14 @@ async function selectRowKeys(condition: SQL): Promise { } beforeAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - userJwt = process.env.USER_JWT - if (!userJwt) return + requireIntegrationEnv(['userjwt']) + userJwt = process.env.USER_JWT as string const crn = process.env.CS_WORKSPACE_CRN if (!crn) throw new Error('CS_WORKSPACE_CRN must be set for lock-context tests') - await installEqlV3IfNeeded(sqlClient) + // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [schema], config: { @@ -193,24 +193,12 @@ beforeAll(async () => { }, 120000) afterAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - if (userJwt) { - await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` - } + await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` await sqlClient.end() }, 30000) -describeLivePg('v3 drizzle operators with lock context (live pg)', () => { - const skipUnlessJwt = (): boolean => { - if (!userJwt) { - console.log('Skipping lock-context operator test - no USER_JWT provided') - return true - } - return false - } - +describe('v3 drizzle operators with lock context (live pg)', () => { it('eq with a matching lock context selects the exact row', async () => { - if (skipUnlessJwt()) return const condition = await ops.eq(secretTable.secret, SECRET_A, { // Runtime accepts a plain { identityClaim } (forwarded to // withLockContext); the operator opts type is narrowed to LockContext. @@ -220,7 +208,6 @@ describeLivePg('v3 drizzle operators with lock context (live pg)', () => { }, 30000) it('a lock-context-bound row decrypts with the same lock context', async () => { - if (skipUnlessJwt()) return const [row] = await sqlClient.unsafe>( `SELECT secret::jsonb AS value FROM ${TABLE_NAME} WHERE test_run_id = $1 AND row_key = $2`, @@ -235,7 +222,6 @@ describeLivePg('v3 drizzle operators with lock context (live pg)', () => { }, 30000) it('eq threads an audit config alongside the lock context', async () => { - if (skipUnlessJwt()) return const condition = await ops.eq(secretTable.secret, SECRET_B, { lockContext: IDENTITY_CLAIM as never, audit: { metadata: { sub: 'toby@cipherstash.com', type: 'query' } }, @@ -255,8 +241,6 @@ describeLivePg('v3 drizzle operators with lock context (live pg)', () => { // (`['sub']`) while ZeroKMS resolves its value from the authenticating token. // No `USER_JWT_B` exists, so that remains a follow-up. it('an identity-bound row does not match an eq issued with no lock context', async () => { - if (skipUnlessJwt()) return - // The control, in this test rather than a sibling one. `[]` is what a held // identity boundary and an unseeded fixture both look like, so the empty // assertion below proves nothing on its own — it only means something @@ -271,7 +255,6 @@ describeLivePg('v3 drizzle operators with lock context (live pg)', () => { }, 30000) it('an identity-bound row does not decrypt without its lock context', async () => { - if (skipUnlessJwt()) return const [row] = await sqlClient.unsafe>( `SELECT secret::jsonb AS value FROM ${TABLE_NAME} WHERE test_run_id = $1 AND row_key = $2`, @@ -296,7 +279,6 @@ describeLivePg('v3 drizzle operators with lock context (live pg)', () => { }, 30000) it('an identity-bound row does not decrypt under a different identity claim', async () => { - if (skipUnlessJwt()) return const [row] = await sqlClient.unsafe>( `SELECT secret::jsonb AS value FROM ${TABLE_NAME} WHERE test_run_id = $1 AND row_key = $2`, diff --git a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts similarity index 85% rename from packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts rename to packages/stack/integration/identity/matrix-identity.integration.test.ts index 8f0477063..e2ac6b6d2 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-identity-live.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -6,30 +6,25 @@ * soft-skip when credentials (and, for lock context, `USER_JWT`) are absent, * mirroring the v2 `audit.test.ts` / lock-context pattern. */ -import 'dotenv/config' +import { requireIntegrationEnv, unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' import { LockContext } from '@/identity' -import { unwrapResult } from '../fixtures' -import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const users = encryptedTable('v3_identity_live_users', { email: types.TextEq('email'), }) -describeLive('v3 typed client identity-aware operations (live)', () => { +describe('v3 typed client identity-aware operations (live)', () => { let client: Awaited>> beforeAll(async () => { + requireIntegrationEnv(['userjwt']) client = await EncryptionV3({ schemas: [users] }) }, 30000) it('round-trips a model with a lock context (encrypt + decrypt bound to identity)', async () => { - const userJwt = process.env.USER_JWT - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } + const userJwt = process.env.USER_JWT as string const lc = new LockContext() const lockContext = await lc.identify(userJwt) diff --git a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts b/packages/stack/integration/shared/matrix-bulk.integration.test.ts similarity index 88% rename from packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts rename to packages/stack/integration/shared/matrix-bulk.integration.test.ts index 61915f15e..0ef71beea 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-bulk.test.ts +++ b/packages/stack/integration/shared/matrix-bulk.integration.test.ts @@ -5,18 +5,16 @@ * `bulkDecryptModels` against real FFI, exercising v3 model reconstruction at * scale. Live soft-skip. */ -import 'dotenv/config' +import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' -import { unwrapResult } from '../fixtures' -import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const people = encryptedTable('v3_bulk_people', { nickname: types.TextEq('nickname'), age: types.IntegerOrd('age'), }) -describeLive('v3 typed client bulk-at-scale (live)', () => { +describe('v3 typed client bulk-at-scale (live)', () => { let client: Awaited>> beforeAll(async () => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts b/packages/stack/integration/shared/matrix-crypto.integration.test.ts similarity index 94% rename from packages/stack/__tests__/v3-matrix/matrix-live.test.ts rename to packages/stack/integration/shared/matrix-crypto.integration.test.ts index ba08e2656..c457b82e7 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live.test.ts +++ b/packages/stack/integration/shared/matrix-crypto.integration.test.ts @@ -17,18 +17,16 @@ * samples (NaN/±Infinity) use the single-value path — the guard throws * client-side before any network — and so stay cheap even one at a time. */ -import 'dotenv/config' -import { beforeAll, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' -import { unwrapResult } from '../fixtures' -import { describeLive } from '../helpers/live-gate' import { type DomainSpec, type EqlV3TypeName, eqlTypeSlug as slug, typedEntries, + unwrapResult, V3_MATRIX, -} from './catalog' +} from '@cipherstash/test-kit' +import { beforeAll, describe, expect, it } from 'vitest' +import { EncryptionV3, encryptedTable } from '@/encryption/v3' // `as const satisfies Record<...>` gives `V3_MATRIX` a narrower type than // `Record` (rows that omit the optional @@ -71,7 +69,7 @@ const errorCases = domains.flatMap(([t, spec]) => ), ) -describeLive('v3 matrix live round-trip (all domains × samples)', () => { +describe('v3 matrix live round-trip (all domains × samples)', () => { let client: Awaited> let encrypted: Array> let decrypted: Array> diff --git a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts b/packages/stack/integration/shared/matrix-keyset.integration.test.ts similarity index 88% rename from packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts rename to packages/stack/integration/shared/matrix-keyset.integration.test.ts index cd6c2a77d..d1bba4ff8 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-keyset.test.ts +++ b/packages/stack/integration/shared/matrix-keyset.integration.test.ts @@ -3,12 +3,11 @@ * The invalid-UUID case is deterministic — validation happens before any network * — so it runs in CI without credentials; the round-trip case is live soft-skip. */ -import 'dotenv/config' + import { ensureKeyset } from '@cipherstash/protect-ffi' +import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' -import { unwrapResult } from '../fixtures' -import { describeLive, LIVE_CIPHERSTASH_ENABLED } from '../helpers/live-gate' const users = encryptedTable('v3_keyset_users', { email: types.TextEq('email'), @@ -27,7 +26,7 @@ describe('EncryptionV3 keyset config (deterministic)', () => { }) }) -describeLive('EncryptionV3 keyset config (live)', () => { +describe('EncryptionV3 keyset config (live)', () => { let keysetId: string beforeAll(async () => { diff --git a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts b/packages/stack/integration/shared/matrix-sql.integration.test.ts similarity index 96% rename from packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts rename to packages/stack/integration/shared/matrix-sql.integration.test.ts index 3d9ad5c68..f17c836d0 100644 --- a/packages/stack/__tests__/v3-matrix/matrix-live-pg.test.ts +++ b/packages/stack/integration/shared/matrix-sql.integration.test.ts @@ -14,7 +14,8 @@ * each kind of v3 domain in SQL — useful reference for engineers and agents * writing new domain-consuming code. * - * ONE mega table (all 39 domains, one column each, like `matrix-live.test.ts`), + * ONE mega table (every covered domain, one column each — ORE domains excluded, + * see the `domains` filter below), * two seeded rows (`samples[0]` / `samples[1]` from the catalog — every domain * has at least two), and per domain one proof per query permutation its indexes * support — proving each selects the expected row and not the other. Beyond the @@ -54,34 +55,28 @@ * operator (NOT `ORDER BY eql_v3.ord_term(col)`) is used deliberately: it is * ORE-correct on both superuser (CI) and non-superuser (local) Postgres. */ -import 'dotenv/config' -import postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' -import { EncryptionV3, encryptedTable } from '@/encryption/v3' -import { unwrapResult } from '../fixtures' -import { installEqlV3IfNeeded } from '../helpers/eql-v3' -import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from '../helpers/live-gate' import { type DomainSpec, + databaseUrl, type EqlV3TypeName, + isCovered, eqlTypeSlug as slug, typedEntries, + unwrapResult, V3_MATRIX, -} from './catalog' +} from '@cipherstash/test-kit' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { EncryptionV3, encryptedTable } from '@/encryption/v3' // Previously force-skipped (CI run 28569708268, PR #540): `beforeAll` crashed // with `PostgresError: invalid input syntax for type json` on the dynamic // 35-column INSERT. Root cause was a postgres.js serialization gap — a bare // ciphertext object stringified to `"[object Object]"` — now fixed by wrapping // every INSERT param in `sql.json(...)` (see `beforeAll`; the fix landed right -// after the skip and the skip was simply left stale). Re-enabled here as an -// ordinary credential-gated suite: it runs in CI (which supplies DATABASE_URL + -// CS_* creds) and self-skips locally when they are absent. +// after the skip and the skip was simply left stale). -const databaseUrl = process.env.DATABASE_URL -const sql = LIVE_EQL_V3_PG_ENABLED - ? postgres(databaseUrl as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sql = postgres(databaseUrl(), { prepare: false }) const TABLE_NAME = 'v3_matrix_live_pg' const TEST_RUN_ID = `matrix-live-pg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -99,7 +94,13 @@ const expectDecryptedStorageValue = ( expect(decrypted).toBe(expected) } -const domains = typedEntries(V3_MATRIX) +// Covered domains only. The nine `_ord_ore` (block-ORE) domains are `deferred`: +// their columns cannot hold data on non-superuser Postgres (the domain CHECK +// raises `ore_domain_unavailable`), so a mega-table built from every row would +// fail to seed on the `supabase/postgres` variant this suite also runs against. +// ORE SQL-operator coverage is a superuser-only follow-up, matching the split +// `relational.integration.test.ts` already makes. +const domains = typedEntries(V3_MATRIX).filter(([, spec]) => isCovered(spec)) const columns = Object.fromEntries( domains.map(([t, spec]) => [slug(t), spec.builder(slug(t))]), @@ -228,9 +229,7 @@ const containedByTerms: Record = {} const orderOperands: Record = {} beforeAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - - await installEqlV3IfNeeded(sql) + // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [table] as never }) const columnDefs = domains @@ -346,14 +345,13 @@ beforeAll(async () => { }, 120000) afterAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return await sql.unsafe(`DELETE FROM ${TABLE_NAME} WHERE test_run_id = ANY($1)`, [ [TEST_RUN_ID, ORDER_RUN_ID], ]) await sql.end() }, 30000) -describeLivePg('v3 matrix live Postgres coverage (all 39 domains)', () => { +describe('v3 matrix live Postgres coverage (all covered domains)', () => { it.each( eqDomains, )('%s: eql_v3.eq(col, operand) selects the exact row', async (eqlType) => { diff --git a/packages/stack/__tests__/schema-v3-pg.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts similarity index 95% rename from packages/stack/__tests__/schema-v3-pg.test.ts rename to packages/stack/integration/shared/schema-pg.integration.test.ts index fa6ed97f6..9d0d2cfc5 100644 --- a/packages/stack/__tests__/schema-v3-pg.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -1,18 +1,12 @@ -import 'dotenv/config' +import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' import postgres from 'postgres' -import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' import type { Encrypted } from '@/types' -import { unwrapResult } from './fixtures' -import { installEqlV3IfNeeded } from './helpers/eql-v3' -import { describeLivePg, LIVE_EQL_V3_PG_ENABLED } from './helpers/live-gate' -const databaseUrl = process.env.DATABASE_URL -const sql = LIVE_EQL_V3_PG_ENABLED - ? postgres(databaseUrl as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sql = postgres(databaseUrl(), { prepare: false }) const table = encryptedTable('protect_ci_v3_text_search', { email: types.TextSearch('email'), @@ -93,9 +87,7 @@ async function seedRows(): Promise> { } beforeAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - - await installEqlV3IfNeeded(sql) + // EQL v3 is installed once per run by `global-setup.ts`. // `eqlVersion: 3` is required for v3 concrete-type schemas: protect-ffi's // newClient defaults to v2, and a v2-mode client cannot encrypt these columns // (it throws "Cannot convert undefined or null to object"). EncryptionV3 sets @@ -133,8 +125,6 @@ beforeAll(async () => { }, 30000) beforeEach(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - await sql` DELETE FROM protect_ci_v3_text_search WHERE test_run_id = ${TEST_RUN_ID} @@ -146,8 +136,6 @@ beforeEach(async () => { }, 30000) afterAll(async () => { - if (!LIVE_EQL_V3_PG_ENABLED) return - await sql` DELETE FROM protect_ci_v3_text_search WHERE test_run_id = ${TEST_RUN_ID} @@ -159,7 +147,7 @@ afterAll(async () => { await sql.end() }, 30000) -describeLivePg('eql_v3 text_search postgres integration', () => { +describe('eql_v3 text_search postgres integration', () => { it('round-trips an encrypted value through an public.eql_v3_text_search column', async () => { const id = await insertRow('roundtrip', 'roundtrip@example.com') @@ -292,11 +280,16 @@ describeLivePg('eql_v3 text_search postgres integration', () => { ON protect_ci_v3_text_search USING btree (eql_v3.ord_term(email)) ` + // No `schemaname` filter: the table is created unqualified, so it lands in + // whatever `search_path` puts first — `public` on a fresh CI database, but + // the owning role's own schema (`cipherstash`) on a database whose + // search_path is `"$user", public`. The unique index names identify the + // indexes on their own; pinning `schemaname = 'public'` made the assertion + // environment-dependent. const indexes = await sql<{ indexname: string; indexdef: string }[]>` SELECT indexname, indexdef FROM pg_indexes - WHERE schemaname = 'public' - AND tablename = 'protect_ci_v3_text_search' + WHERE tablename = 'protect_ci_v3_text_search' AND indexname IN ( 'protect_ci_v3_text_search_email_eq_idx', 'protect_ci_v3_text_search_email_match_idx', diff --git a/packages/stack/__tests__/schema-v3-client.test.ts b/packages/stack/integration/shared/schema-v3-client.integration.test.ts similarity index 98% rename from packages/stack/__tests__/schema-v3-client.test.ts rename to packages/stack/integration/shared/schema-v3-client.integration.test.ts index 458039be6..839ded21a 100644 --- a/packages/stack/__tests__/schema-v3-client.test.ts +++ b/packages/stack/integration/shared/schema-v3-client.integration.test.ts @@ -1,11 +1,9 @@ -import 'dotenv/config' +import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import type { EncryptionClient } from '@/encryption' import { typedClient } from '@/encryption/v3' import { encryptedTable, types } from '@/eql/v3' import { Encryption } from '@/index' -import { unwrapResult } from './fixtures' -import { describeLive, LIVE_CIPHERSTASH_ENABLED } from './helpers/live-gate' const users = encryptedTable('schema_v3_client_users', { email: types.TextSearch('email'), @@ -21,7 +19,7 @@ const users = encryptedTable('schema_v3_client_users', { occurredAt: types.Timestamp('occurred_at'), }) -describeLive('eql_v3 client integration', () => { +describe('eql_v3 client integration', () => { let protectClient: EncryptionClient beforeAll(async () => { diff --git a/packages/stack/__tests__/supabase-v3-grants-pg.test.ts b/packages/stack/integration/supabase/grants.integration.test.ts similarity index 91% rename from packages/stack/__tests__/supabase-v3-grants-pg.test.ts rename to packages/stack/integration/supabase/grants.integration.test.ts index 768f030c3..74bae3421 100644 --- a/packages/stack/__tests__/supabase-v3-grants-pg.test.ts +++ b/packages/stack/integration/supabase/grants.integration.test.ts @@ -29,20 +29,15 @@ * `DATABASE_URL`-only gate, alongside `supabase-v3-introspect-pg`. */ -import 'dotenv/config' +import { databaseUrl } from '@cipherstash/test-kit' import postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { SUPABASE_PERMISSIONS_SQL_V3, supabaseInternalPermissionsSql, -} from '../../cli/src/installer/grants' -import { installEqlV3IfNeeded } from './helpers/eql-v3' -import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate' +} from '../../../cli/src/installer/grants' -const databaseUrl = process.env.DATABASE_URL -const sql = LIVE_PG_ENABLED - ? postgres(databaseUrl as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sql = postgres(databaseUrl(), { prepare: false }) const INTERNAL_SCHEMA = 'eql_v3_internal' @@ -79,8 +74,9 @@ async function probeAsAnon(): Promise { } beforeAll(async () => { - if (!LIVE_PG_ENABLED) return - await installEqlV3IfNeeded(sql) + // EQL v3 (and its `--supabase` grants) is installed once per run by + // `global-setup.ts`; this suite then revokes and re-applies the grant SQL in + // isolation to prove that SQL, so it needs the schema to already exist. // `CREATE ROLE IF NOT EXISTS` does not exist; a shared CI database may // already carry these from an earlier run. @@ -111,11 +107,10 @@ beforeAll(async () => { }, 120_000) afterAll(async () => { - if (!LIVE_PG_ENABLED) return await sql.end() }) -describeLivePgOnly('supabase v3 grants against real Postgres', () => { +describe('supabase v3 grants against real Postgres', () => { it('grants anon USAGE on eql_v3_internal', async () => { const [row] = await sql<{ usage: boolean }[]>` SELECT has_schema_privilege('anon', ${INTERNAL_SCHEMA}, 'USAGE') AS usage diff --git a/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts b/packages/stack/integration/supabase/introspect.integration.test.ts similarity index 83% rename from packages/stack/__tests__/supabase-v3-introspect-pg.test.ts rename to packages/stack/integration/supabase/introspect.integration.test.ts index e9390d3e1..69d394d60 100644 --- a/packages/stack/__tests__/supabase-v3-introspect-pg.test.ts +++ b/packages/stack/integration/supabase/introspect.integration.test.ts @@ -1,24 +1,18 @@ -import 'dotenv/config' +import { databaseUrl } from '@cipherstash/test-kit' import postgres from 'postgres' -import { afterAll, beforeAll, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { factoryForDomain } from '@/eql/v3/domain-registry' import { introspect } from '@/supabase/introspect' import { synthesizeTables } from '@/supabase/schema-builder' -import { installEqlV3IfNeeded } from './helpers/eql-v3' -import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate' -const databaseUrl = process.env.DATABASE_URL -const sql = LIVE_PG_ENABLED - ? postgres(databaseUrl as string, { prepare: false }) - : (undefined as unknown as postgres.Sql) +const sql = postgres(databaseUrl(), { prepare: false }) const MODELLED = 'protect_ci_v3_introspect' const UNMODELLED = 'protect_ci_v3_unmodelled' const USER_DOMAIN = 'protect_ci_v3_user_json' beforeAll(async () => { - if (!LIVE_PG_ENABLED) return - await installEqlV3IfNeeded(sql) + // EQL v3 is installed once per run by `global-setup.ts`. await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`) await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`) await sql.unsafe(` @@ -46,16 +40,15 @@ beforeAll(async () => { }, 30000) afterAll(async () => { - if (!LIVE_PG_ENABLED) return await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`) await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`) await sql.unsafe(`DROP DOMAIN IF EXISTS public.${USER_DOMAIN}`) await sql.end() }, 30000) -describeLivePgOnly('eql_v3 supabase introspection', () => { +describe('eql_v3 supabase introspection', () => { it('detects EQL v3 domains and classifies plaintext columns', async () => { - const { tables } = await introspect(databaseUrl as string) + const { tables } = await introspect(databaseUrl()) const table = tables.find((t) => t.tableName === MODELLED) expect(table).toBeDefined() const domains = Object.fromEntries( @@ -70,7 +63,7 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { }, 30000) it('round-trips the domain → builder mapping via synthesizeTables', async () => { - const { tables } = await introspect(databaseUrl as string) + const { tables } = await introspect(databaseUrl()) const { tables: synth, allColumns } = synthesizeTables(tables) const table = synth.get(MODELLED) @@ -97,7 +90,7 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { // `UNMODELLED_COLUMNS_QUERY`: EQL-by-COMMENT, and not in `DOMAIN_REGISTRY`. // These prove it against a real catalog — nothing else does. it('reports unmodelled EQL columns, keyed by table', async () => { - const { unmodelled } = await introspect(databaseUrl as string) + const { unmodelled } = await introspect(databaseUrl()) // Sanity: these really are EQL domains with no types factory. expect(factoryForDomain('integer_ord_ope')).toBeUndefined() @@ -112,12 +105,12 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { }, 30000) it('does not report a fully-modelled table', async () => { - const { unmodelled } = await introspect(databaseUrl as string) + const { unmodelled } = await introspect(databaseUrl()) expect(unmodelled.has(MODELLED)).toBe(false) }, 30000) it("does not report a user's own jsonb domain as unmodelled", async () => { - const { unmodelled } = await introspect(databaseUrl as string) + const { unmodelled } = await introspect(databaseUrl()) // `own` carries a public domain with NO EQL comment → plaintext, not a leak. const offenders = unmodelled.get(UNMODELLED) ?? [] expect(offenders.map((c) => c.columnName)).not.toContain('own') @@ -127,7 +120,7 @@ describeLivePgOnly('eql_v3 supabase introspection', () => { // column is silently dropped from the encrypt config, yet stays in // `allColumns` — so `select('*')` would select it and return raw ciphertext. it('synthesizeTables drops an unmodelled column but allColumns keeps it', async () => { - const { tables } = await introspect(databaseUrl as string) + const { tables } = await introspect(databaseUrl()) const { tables: synth, allColumns } = synthesizeTables(tables) expect(Object.keys(synth.get(UNMODELLED)!.columnBuilders)).toEqual([]) diff --git a/packages/test-kit/src/env.ts b/packages/test-kit/src/env.ts index cb68fe83d..91df7c973 100644 --- a/packages/test-kit/src/env.ts +++ b/packages/test-kit/src/env.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' * to fix it. */ -export type Requirement = 'cipherstash' | 'database' | 'pgrest' +export type Requirement = 'cipherstash' | 'database' | 'pgrest' | 'userjwt' /** Every variable protect-ffi's `AutoStrategy` needs to authenticate from the environment. */ const CS_VARS = [ @@ -87,6 +87,13 @@ const HINTS: Record string | null> = { : 'PGRST_URL. The Supabase adapter speaks PostgREST, not Postgres:\n' + ' docker compose -f local/docker-compose.supabase.yml up -d --wait\n' + ' export PGRST_URL=http://localhost:55430', + + userjwt: () => + process.env['USER_JWT'] + ? null + : 'USER_JWT. The identity / lock-context suites bind a data key to an\n' + + ' end-user JWT claim, so they need a real token:\n' + + ' export USER_JWT=', } /** diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 96fb520f5..7575c1a6a 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -56,6 +56,7 @@ export { plainValue, sortedKeysFor, } from './oracle.ts' +export { unwrapResult } from './results.ts' export { NULL_ROW_KEY, type PlainRow, diff --git a/packages/test-kit/src/results.ts b/packages/test-kit/src/results.ts new file mode 100644 index 000000000..4981a2774 --- /dev/null +++ b/packages/test-kit/src/results.ts @@ -0,0 +1,16 @@ +/** + * Unwrap a `@byteslice/result`-shaped `{ data } | { failure }` value in a test, + * throwing the failure message on the error arm. The integration suites and the + * ported live suites all assert on the success arm, so this keeps the + * `if (result.failure) throw` boilerplate in one place rather than re-declaring + * a local `unwrap()` per file. + */ +export function unwrapResult(result: { + data?: T + failure?: { message: string } +}): T { + if (result.failure) { + throw new Error(result.failure.message) + } + return result.data as T +} From f143dfecc1af5746b202bd8c382eb4843ae23c41 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 22:43:43 +1000 Subject: [PATCH 02/11] test(stack): federate a Clerk M2M JWT for the identity suites; wire them into CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the never-provisioned `USER_JWT` env var with a freshly-minted Clerk machine-to-machine token, federated into a CTS token via `OidcFederationStrategy`. Nothing long-lived is stored: the strategy re-invokes the `getJwt` callback on every re-federation (CTS issues no refresh token), so the short-lived token self-heals; the only secret is `CLERK_MACHINE_TOKEN`, a rotatable machine key. Proven end to end against a real workspace: the Clerk M2M JWT federates and CTS resolves the identity to the machine subject (`CS|mch_…`) — a stable `sub` for the symmetric round-trip and the no-context negative path. (A second identity, for the cross-identity test, needs a second machine token — still a follow-up.) - `integration/helpers/clerk.ts`: `clerkJwtProvider()` mints a JWT-format M2M token via `@clerk/backend` (added as a stack devDep; already in the lockfile via `@clerk/nextjs`, so no new download), gated on `CLERK_MACHINE_TOKEN`. - `lock-context` / `matrix-identity`: use `OidcFederationStrategy.create(crn, clerkJwtProvider())`, unwrapping the `Result` to the strategy (the old never-run code passed the `Result` to `config.strategy`, which has no `.getToken` — a latent bug). `matrix-identity` migrates off the deprecated `LockContext.identify()` onto the strategy. - test-kit: the `userjwt` requirement becomes `clerk` (asserts `CLERK_MACHINE_TOKEN`). - CI: the Drizzle job gains `CLERK_MACHINE_TOKEN` (job env only, NOT the require-cs-secrets preflight — a missing token fails just the identity suites, loudly, not the whole job) and `integration/identity/**` joins its suite globs. The target workspace must have the Clerk issuer registered on its OIDC-providers page and a keyset. The full encrypt→decrypt round-trip is verified in CI (real workspace creds), not locally: a local run needs the workspace's access key plus the Clerk issuer registered on that same workspace. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .github/workflows/integration-drizzle.yml | 19 ++++--- .github/workflows/integration-supabase.yml | 5 +- packages/stack/integration/helpers/clerk.ts | 45 +++++++++++++++ .../identity/lock-context.integration.test.ts | 55 +++++++++---------- .../matrix-identity.integration.test.ts | 54 +++++++++++------- packages/stack/package.json | 3 +- packages/test-kit/src/env.ts | 15 +++-- pnpm-lock.yaml | 3 + 8 files changed, 133 insertions(+), 66 deletions(-) create mode 100644 packages/stack/integration/helpers/clerk.ts diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index 6ef27bb9f..eaa7dc168 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -72,6 +72,11 @@ jobs: CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + # The identity suites federate a freshly-minted Clerk M2M JWT into a CTS + # token. Only they read this; the other suites ignore it. If it is unset + # they fail loudly (throw, never skip) rather than taking the whole job + # down, so it is NOT part of the require-cs-secrets preflight. + CLERK_MACHINE_TOKEN: ${{ secrets.CLERK_MACHINE_TOKEN }} # Job-level env, not a `.env` file: `dotenv/config` does not override an # already-set `process.env`, so these win and no secret is written to disk. DATABASE_URL: ${{ matrix.database-url }} @@ -83,15 +88,15 @@ jobs: CS_IT_DB_VARIANT: ${{ matrix.db }} # Scoped by directory, never by named file, so a renamed suite cannot # silently drop from CI. `integration/shared/` holds the adapter-agnostic - # suites (harness, bloom, ope-term, the crypto/SQL matrices); the Supabase - # adapter suites are not run here — they have their own job. - # - # `integration/identity/` is DELIBERATELY absent: those suites require a - # `USER_JWT` that CI does not yet provision (#530). They throw rather than - # skip when run without it, and join this list once the secret lands. + # suites (harness, bloom, ope-term, the crypto/SQL matrices); + # `integration/identity/` holds the Clerk-federated lock-context suites + # (they need CLERK_MACHINE_TOKEN + a workspace with the Clerk issuer + # registered); the Supabase adapter suites are not run here — they have + # their own job. CS_IT_SUITE: >- integration/shared/**/*.integration.test.ts, - integration/drizzle-v3/**/*.integration.test.ts + integration/drizzle-v3/**/*.integration.test.ts, + integration/identity/**/*.integration.test.ts steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 9b24e2f49..87d4f3592 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -70,9 +70,8 @@ jobs: # ordering path, and the crypto/SQL matrices); the Drizzle suites talk to # plain Postgres and get their own job. # - # `integration/identity/` is DELIBERATELY absent: those suites require a - # `USER_JWT` that CI does not yet provision (#530). They throw rather than - # skip when run without it, and join this list once the secret lands. + # `integration/identity/` runs on the Drizzle job (it needs Postgres + + # Drizzle, not PostgREST), so it is intentionally not listed here. CS_IT_SUITE: >- integration/shared/**/*.integration.test.ts, integration/supabase/**/*.integration.test.ts diff --git a/packages/stack/integration/helpers/clerk.ts b/packages/stack/integration/helpers/clerk.ts new file mode 100644 index 000000000..67e66df54 --- /dev/null +++ b/packages/stack/integration/helpers/clerk.ts @@ -0,0 +1,45 @@ +import { requireIntegrationEnv } from '@cipherstash/test-kit' +import { createClerkClient } from '@clerk/backend' + +/** + * A `getJwt` provider for `OidcFederationStrategy` that mints a fresh Clerk + * machine-to-machine token (JWT) on demand. + * + * Why this instead of a stored `USER_JWT`: the strategy re-invokes `getJwt` on + * every (re-)federation and CTS issues no refresh token, so a short-lived, + * freshly-minted token self-heals on expiry — nothing long-lived is ever kept + * in an env var or a CI secret. The only secret is `CLERK_MACHINE_TOKEN`, a + * rotatable machine key. + * + * The federated identity is the Clerk machine (`CTS|CS|mch_…`), which is stable + * across encrypt and decrypt — enough for the symmetric round-trip and the + * no-context negative path. The cross-identity test needs a SECOND machine + * token (a distinct `sub`) and remains a follow-up. + * + * The workspace named by `CS_WORKSPACE_CRN` must have this Clerk instance + * registered on its OIDC-providers page, or `/api/authorise` rejects the token. + */ +export function clerkJwtProvider(): () => Promise { + requireIntegrationEnv(['clerk']) + const machineSecretKey = process.env['CLERK_MACHINE_TOKEN'] as string + + // `createToken` authenticates with `machineSecretKey`; `createClerkClient` + // still wants a `secretKey` string at construction that this path never uses. + const clerk = createClerkClient({ + secretKey: process.env['CLERK_SECRET_KEY'] ?? 'sk_test_placeholder', + }) + + return async () => { + const m2m = await clerk.m2m.createToken({ + machineSecretKey, + tokenFormat: 'jwt', + secondsUntilExpiration: 300, + }) + if (typeof m2m.token !== 'string') { + throw new Error( + 'Clerk M2M createToken did not return a JWT — enable the JWT token format for the machine in the Clerk Dashboard.', + ) + } + return m2m.token + } +} diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index 81a5c6269..39bc902d2 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -9,17 +9,14 @@ * { lockContext })` against a real database and assert the encrypted term * actually matches the stored row and decrypts. * - * Wiring mirrors `lock-context.test.ts` (the current, non-deprecated - * strategy-based flow): the client authenticates as the end user via - * `OidcFederationStrategy` and binds the key to the `sub` claim with a plain - * `.withLockContext({ identityClaim })`. + * The client authenticates via `OidcFederationStrategy`, federating a + * freshly-minted Clerk M2M JWT (`clerkJwtProvider()`) into a CTS token, and + * binds the key to the `sub` claim — resolved by ZeroKMS from the token, here + * the Clerk machine identity — with a plain `.withLockContext({ identityClaim })`. * * Lives under `integration/identity/`: like every integration suite it THROWS - * rather than skips when unconfigured, and additionally requires `USER_JWT` - * (asserted in `beforeAll` via `requireIntegrationEnv(['userjwt'])`). That - * directory is not yet in any CI job's suite globs — the `USER_JWT` repo secret - * is unprovisioned (#530) — so these run locally with a token today and join CI - * once the secret lands. Whether the searchable index terms are themselves + * rather than skips when unconfigured (`clerkJwtProvider()` asserts + * `CLERK_MACHINE_TOKEN`). Whether the searchable index terms are themselves * identity-bound is decided inside `@cipherstash/protect-ffi`, not this repo. * * We assert the symmetric behaviour (same lock context on seed + query matches @@ -33,11 +30,7 @@ * ZeroKMS resolves its value from the authenticating token. Tracked separately. */ import { OidcFederationStrategy } from '@cipherstash/auth' -import { - databaseUrl, - requireIntegrationEnv, - V3_MATRIX, -} from '@cipherstash/test-kit' +import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' @@ -49,6 +42,7 @@ import { extractEncryptionSchemaV3, } from '@/eql/v3/drizzle' import { makeEqlV3Column } from '@/eql/v3/drizzle/column' +import { clerkJwtProvider } from '../helpers/clerk' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -77,7 +71,6 @@ type SelectRow = { rowKey: string } let client: Awaited> let ops: ReturnType let db: ReturnType -let userJwt: string | undefined function unwrap(result: { data?: T; failure?: { message: string } }): T { if (result.failure) throw new Error(result.failure.message) @@ -128,8 +121,8 @@ const KEY_DENIAL = /^Failed to retrieve key/ * * What must NOT pass is an infrastructure fault masquerading as a denial, so * that is excluded separately. Kept loose rather than pinned to `KEY_DENIAL` - * because no CI run exercises this path — `USER_JWT` is unset in CI (#530) — - * and a wrong message-shape guess would only surface once that secret lands. + * because which of the two denials surfaces is a ZeroKMS server-side detail, + * and the Clerk machine token may or may not carry an `email` claim. */ const IDENTITY_DENIAL = /failed to retrieve key|unauthoriz|unauthoris|forbidden|denied|not authorized|not authorised/i @@ -149,21 +142,24 @@ async function selectRowKeys(condition: SQL): Promise { } beforeAll(async () => { - requireIntegrationEnv(['userjwt']) - userJwt = process.env.USER_JWT as string - const crn = process.env.CS_WORKSPACE_CRN if (!crn) throw new Error('CS_WORKSPACE_CRN must be set for lock-context tests') - // EQL v3 is installed once per run by `global-setup.ts`. + // A freshly-minted Clerk M2M JWT federates into a CTS token; the strategy + // re-mints via this callback on expiry. `clerkJwtProvider()` asserts + // CLERK_MACHINE_TOKEN (throws if absent). EQL v3 is installed once per run by + // `global-setup.ts`. + // + // `create()` returns a `Result` — unwrap to the strategy itself, which is what + // `config.strategy` expects (it calls `.getToken()` on it). + const federation = OidcFederationStrategy.create(crn, clerkJwtProvider()) + if (federation.failure) { + throw new Error(`[federation]: ${federation.failure.message}`) + } client = await EncryptionV3({ schemas: [schema], - config: { - strategy: OidcFederationStrategy.create(crn, () => - Promise.resolve(userJwt as string), - ), - }, + config: { strategy: federation.data }, }) ops = createEncryptionOperatorsV3(client) db = drizzle({ client: sqlClient }) @@ -237,9 +233,10 @@ describe('v3 drizzle operators with lock context (live pg)', () => { // context — the property the suite exists to protect. // // A true CROSS-identity proof (sealed under A, queried under B) needs a - // second JWT with a different `sub`; the lock context only names the claim - // (`['sub']`) while ZeroKMS resolves its value from the authenticating token. - // No `USER_JWT_B` exists, so that remains a follow-up. + // SECOND machine identity with a different `sub`; the lock context only names + // the claim (`['sub']`) while ZeroKMS resolves its value from the + // authenticating token. Only one Clerk machine token is wired up, so that + // remains a follow-up. it('an identity-bound row does not match an eq issued with no lock context', async () => { // The control, in this test rather than a sibling one. `[]` is what a held // identity boundary and an unseeded fixture both look like, so the empty diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index e2ac6b6d2..a6574e14c 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -1,47 +1,61 @@ /** - * Live identity-aware coverage for the v3 typed client: lock-context round-trips - * and audit metadata. Kept separate from `matrix-lock-context.test.ts` because - * that file mocks `@cipherstash/protect-ffi` file-wide — a mock would neutralize - * a "live" assertion. No mock here: these hit a real CipherStash workspace and - * soft-skip when credentials (and, for lock context, `USER_JWT`) are absent, - * mirroring the v2 `audit.test.ts` / lock-context pattern. + * Live identity-aware coverage for the v3 typed client: a lock-context + * encrypt→decrypt round-trip through the MODEL path, plus audit metadata. + * + * Kept separate from `matrix-lock-context.test.ts`, which mocks + * `@cipherstash/protect-ffi` file-wide — a mock would neutralize a "live" + * assertion. No mock here: it federates a freshly-minted Clerk M2M JWT into a + * CTS token via `OidcFederationStrategy` and binds the lock context to `sub` + * (resolved by ZeroKMS from the token — here, the Clerk machine identity). + * + * Lives under `integration/identity/`, which is not yet in the CI suite globs + * (see the integration workflows): it THROWS rather than skips without + * `CLERK_MACHINE_TOKEN`, and joins CI once that workspace/secret is wired up. */ -import { requireIntegrationEnv, unwrapResult } from '@cipherstash/test-kit' +import { OidcFederationStrategy } from '@cipherstash/auth' +import { unwrapResult } from '@cipherstash/test-kit' import { beforeAll, describe, expect, it } from 'vitest' import { EncryptionV3, encryptedTable, types } from '@/encryption/v3' -import { LockContext } from '@/identity' +import { clerkJwtProvider } from '../helpers/clerk' const users = encryptedTable('v3_identity_live_users', { email: types.TextEq('email'), }) +const LOCK = { identityClaim: ['sub'] } + describe('v3 typed client identity-aware operations (live)', () => { let client: Awaited>> beforeAll(async () => { - requireIntegrationEnv(['userjwt']) - client = await EncryptionV3({ schemas: [users] }) + const crn = process.env.CS_WORKSPACE_CRN + if (!crn) { + throw new Error('CS_WORKSPACE_CRN must be set for identity tests') + } + // `create()` returns a `Result`; unwrap to the strategy `config.strategy` + // expects. `clerkJwtProvider()` asserts CLERK_MACHINE_TOKEN and re-mints on + // every re-federation. + const federation = OidcFederationStrategy.create(crn, clerkJwtProvider()) + if (federation.failure) { + throw new Error(`[federation]: ${federation.failure.message}`) + } + client = await EncryptionV3({ + schemas: [users], + config: { strategy: federation.data }, + }) }, 30000) it('round-trips a model with a lock context (encrypt + decrypt bound to identity)', async () => { - const userJwt = process.env.USER_JWT as string - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - const encrypted = unwrapResult( await client .encryptModel({ email: 'ada@example.com' }, users) - .withLockContext(lockContext.data), + .withLockContext(LOCK), ) expect(encrypted.email).toHaveProperty('c') // decryptModel takes the lock context as a positional 3rd arg. const decrypted = unwrapResult( - await client.decryptModel(encrypted, users, lockContext.data), + await client.decryptModel(encrypted, users, LOCK as never), ) expect(decrypted.email).toBe('ada@example.com') }, 30000) diff --git a/packages/stack/package.json b/packages/stack/package.json index 6cbac0229..f66d99b86 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -230,6 +230,7 @@ "devDependencies": { "@cipherstash/eql": "3.0.0", "@clack/prompts": "^1.4.0", + "@clerk/backend": "3.4.9", "@supabase/postgrest-js": "2.105.4", "@supabase/supabase-js": "^2.105.4", "@types/pg": "^8.20.0", @@ -258,7 +259,7 @@ "uuid": "14.0.0", "zod": "3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies \u2014 pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Required because @cipherstash/stack re-exports the Node auth strategies (OidcFederationStrategy, AccessKeyStrategy, \u2026). All seven names share a single catalog entry to keep them in lockstep.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Required because @cipherstash/stack re-exports the Node auth strategies (OidcFederationStrategy, AccessKeyStrategy, …). All seven names share a single catalog entry to keep them in lockstep.", "optionalDependencies": { "@cipherstash/auth-darwin-arm64": "catalog:repo", "@cipherstash/auth-darwin-x64": "catalog:repo", diff --git a/packages/test-kit/src/env.ts b/packages/test-kit/src/env.ts index 91df7c973..7fc7b4327 100644 --- a/packages/test-kit/src/env.ts +++ b/packages/test-kit/src/env.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' * to fix it. */ -export type Requirement = 'cipherstash' | 'database' | 'pgrest' | 'userjwt' +export type Requirement = 'cipherstash' | 'database' | 'pgrest' | 'clerk' /** Every variable protect-ffi's `AutoStrategy` needs to authenticate from the environment. */ const CS_VARS = [ @@ -88,12 +88,15 @@ const HINTS: Record string | null> = { ' docker compose -f local/docker-compose.supabase.yml up -d --wait\n' + ' export PGRST_URL=http://localhost:55430', - userjwt: () => - process.env['USER_JWT'] + clerk: () => + process.env['CLERK_MACHINE_TOKEN'] ? null - : 'USER_JWT. The identity / lock-context suites bind a data key to an\n' + - ' end-user JWT claim, so they need a real token:\n' + - ' export USER_JWT=', + : 'CLERK_MACHINE_TOKEN. The identity / lock-context suites federate a\n' + + ' freshly-minted Clerk machine (M2M) JWT into a CTS token, so they need\n' + + " the machine's secret key (ak_...):\n" + + ' export CLERK_MACHINE_TOKEN=ak_...\n' + + ' The CS_WORKSPACE_CRN workspace must have that Clerk instance registered\n' + + ' on its OIDC-providers page.', } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c8575260..e67fa7788 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,9 @@ importers: '@clack/prompts': specifier: ^1.4.0 version: 1.4.0 + '@clerk/backend': + specifier: 3.4.9 + version: 3.4.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@supabase/postgrest-js': specifier: 2.105.4 version: 2.105.4 From 2c33a9995c504ae1565ac10677e5875b1f2d2906 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 22:50:11 +1000 Subject: [PATCH 03/11] ci: read CS_WORKSPACE_CRN and CS_CLIENT_ID as repository variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two are workspace/client identifiers, not credentials (the sensitive values are CS_CLIENT_KEY and CS_CLIENT_ACCESS_KEY), so they are now stored as repository *variables* rather than secrets — which keeps them unmasked in logs for easier debugging. Repository variables resolve via `${{ vars.X }}`, not `${{ secrets.X }}`, so every caller (both integration workflows, tests.yml, the two prisma e2e workflows, and the require-cs-secrets inputs) is switched over. CS_CLIENT_KEY / CS_CLIENT_ACCESS_KEY / CLERK_MACHINE_TOKEN stay secrets. Without this the require-cs-secrets preflight fails with empty CS_WORKSPACE_CRN / CS_CLIENT_ID. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .github/actions/require-cs-secrets/action.yml | 4 +- .github/workflows/integration-drizzle.yml | 8 +-- .github/workflows/integration-supabase.yml | 8 +-- .../workflows/prisma-example-readme-e2e.yml | 8 +-- .github/workflows/prisma-next-e2e.yml | 12 ++--- .github/workflows/tests.yml | 52 +++++++++---------- 6 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/actions/require-cs-secrets/action.yml b/.github/actions/require-cs-secrets/action.yml index 55b39f1dc..95fee4238 100644 --- a/.github/actions/require-cs-secrets/action.yml +++ b/.github/actions/require-cs-secrets/action.yml @@ -8,10 +8,10 @@ description: >- inputs: workspace-crn: - description: secrets.CS_WORKSPACE_CRN + description: vars.CS_WORKSPACE_CRN required: true client-id: - description: secrets.CS_CLIENT_ID + description: vars.CS_CLIENT_ID required: true client-key: description: secrets.CS_CLIENT_KEY diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index eaa7dc168..ae313afc2 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -68,8 +68,8 @@ jobs: pgrest-url: http://localhost:55430 env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} # The identity suites federate a freshly-minted Clerk M2M JWT into a CTS @@ -108,8 +108,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 87d4f3592..8411746e9 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -50,8 +50,8 @@ jobs: db: [supabase] env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} # Job-level env, not a `.env` file: `dotenv/config` does not override an @@ -86,8 +86,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} diff --git a/.github/workflows/prisma-example-readme-e2e.yml b/.github/workflows/prisma-example-readme-e2e.yml index 954b0b82f..bd12cd64f 100644 --- a/.github/workflows/prisma-example-readme-e2e.yml +++ b/.github/workflows/prisma-example-readme-e2e.yml @@ -36,8 +36,8 @@ jobs: if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -70,8 +70,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index abc7ff82c..02805a440 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -38,8 +38,8 @@ jobs: if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -73,8 +73,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -89,8 +89,8 @@ jobs: run: | touch ./examples/prisma/.env echo "DATABASE_URL=postgres://cipherstash:cipherstash@localhost:54329/cipherstash_e2e" >> ./examples/prisma/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./examples/prisma/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./examples/prisma/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./examples/prisma/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./examples/prisma/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./examples/prisma/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./examples/prisma/.env diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index df99f4c85..7f970e250 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,8 +64,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -97,8 +97,8 @@ jobs: - name: Create .env file in ./packages/protect/ run: | touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env @@ -106,8 +106,8 @@ jobs: - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/stack/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/stack/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/stack/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/stack/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env @@ -115,16 +115,16 @@ jobs: - name: Create .env file in ./packages/protect-dynamodb/ run: | touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - name: Create .env file in ./packages/drizzle/ run: | touch ./packages/drizzle/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/drizzle/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/drizzle/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/drizzle/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/drizzle/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/drizzle/.env @@ -147,8 +147,8 @@ jobs: # are set. We expose them at the job level so the wizard subprocess # picks them up via `process.env`. env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} CS_ZEROKMS_HOST: https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net @@ -183,8 +183,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -211,8 +211,8 @@ jobs: # identity and region — the stack /wasm-inline config requires it and # derives the AccessKeyStrategy region from it. env: - CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} - CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -262,8 +262,8 @@ jobs: - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: - workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }} - client-id: ${{ secrets.CS_CLIENT_ID }} + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -317,8 +317,8 @@ jobs: - name: Create .env file in ./packages/protect/ run: | touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env @@ -326,8 +326,8 @@ jobs: - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/stack/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/stack/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/stack/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/stack/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env @@ -335,16 +335,16 @@ jobs: - name: Create .env file in ./packages/protect-dynamodb/ run: | touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - name: Create .env file in ./packages/drizzle/ run: | touch ./packages/drizzle/.env - echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env - echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/drizzle/.env + echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/drizzle/.env + echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/drizzle/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/drizzle/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/drizzle/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/drizzle/.env From 8d707cc9bc6753885ddc9d6a210dcf96ef580a2f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 23:11:11 +1000 Subject: [PATCH 04/11] =?UTF-8?q?test(stack):=20search=20terms=20are=20not?= =?UTF-8?q?=20identity-bound=20=E2=80=94=20decryption=20is=20the=20boundar?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock-context negative test assumed the equality term was identity-bound and asserted a no-context `eq` returns nothing. Against a real workspace it returns the row: the `hm`/`eq_term` HMAC is workspace-scoped, so search matches with or without the lock context. The identity boundary is enforced at DECRYPTION — the sibling test (decrypt denied without the context) passes and is unchanged. Corrected the assertion and the surrounding comments to the observed behaviour. Whether searchable terms SHOULD be identity-bound is a CipherStash design question raised with the team; this documents what the SDK does today. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../identity/lock-context.integration.test.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index 39bc902d2..683d91dad 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -20,10 +20,12 @@ * identity-bound is decided inside `@cipherstash/protect-ffi`, not this repo. * * We assert the symmetric behaviour (same lock context on seed + query matches - * and decrypts) AND the negative path — an identity-bound row must not match a - * query issued with no lock context, and must not decrypt without it. The - * symmetric tests alone are insufficient: they drop the context identically on - * both sides, so they stay green even if it were ignored entirely. + * and decrypts) AND the negative path. The boundary is at DECRYPTION: an + * identity-bound row must not decrypt without its context. Search is NOT the + * boundary — the equality term is workspace-scoped, so a no-context query still + * matches (see the negative-path tests). The symmetric tests alone are + * insufficient: they drop the context identically on both sides, so they stay + * green even if it were ignored entirely. * * A cross-identity non-match (sealed under A, queried under B) still needs a * second JWT with a different `sub` — a lock context only names the claim while @@ -225,30 +227,34 @@ describe('v3 drizzle operators with lock context (live pg)', () => { expect(await selectRowKeys(condition)).toEqual([ROW_B]) }, 30000) - // NEGATIVE PATH. The three tests above all supply the SAME lock context on - // seed and on query, so they cannot distinguish "lock context applied" from - // "lock context silently ignored": a regression that dropped the context from - // the index term would drop it identically on both sides and still match. - // These assert that an identity-bound row is NOT reachable without its - // context — the property the suite exists to protect. + // NEGATIVE PATH. The identity boundary is enforced at DECRYPTION, not at + // search. The equality term (`hm`/`eq_term` HMAC) is workspace-scoped: a query + // WITHOUT the lock context produces the same term and matches the same row — + // but the value still cannot be decrypted without the context (second test + // below). To run the no-context query at all you must already know the + // plaintext to build the term, and you can confirm a match but never read the + // row. + // + // Whether search terms SHOULD be identity-bound is a CipherStash design + // question, raised with the team. An earlier version of the first test + // assumed they were and asserted `[]`; this is corrected to the OBSERVED + // behaviour, with the real boundary proven by the decryption test. // // A true CROSS-identity proof (sealed under A, queried under B) needs a // SECOND machine identity with a different `sub`; the lock context only names // the claim (`['sub']`) while ZeroKMS resolves its value from the // authenticating token. Only one Clerk machine token is wired up, so that // remains a follow-up. - it('an identity-bound row does not match an eq issued with no lock context', async () => { - // The control, in this test rather than a sibling one. `[]` is what a held - // identity boundary and an unseeded fixture both look like, so the empty - // assertion below proves nothing on its own — it only means something - // against a demonstration that the row IS reachable with the context. + it('an eq matches the same row with or without a lock context (search is not identity-bound)', async () => { const bound = await ops.eq(secretTable.secret, SECRET_A, { lockContext: IDENTITY_CLAIM as never, }) expect(await selectRowKeys(bound)).toEqual([ROW_A]) + // Same HMAC term, no context — still matches. Decryption, not search, is + // the identity boundary (proven by the next test). const unbound = await ops.eq(secretTable.secret, SECRET_A) - expect(await selectRowKeys(unbound)).toEqual([]) + expect(await selectRowKeys(unbound)).toEqual([ROW_A]) }, 30000) it('an identity-bound row does not decrypt without its lock context', async () => { From 0537e630eed18a51ef8057f4232ee6ac1f1b5230 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 23:17:52 +1000 Subject: [PATCH 05/11] test(stack): use config.authStrategy instead of the deprecated config.strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity suites passed a federation strategy via `config.strategy`, which logs a deprecation warning ("use config.authStrategy instead"). Switched both to `authStrategy`. Runtime behaviour is unchanged — the client honours both — this just drops the warning from the integration logs. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../integration/identity/lock-context.integration.test.ts | 4 ++-- .../integration/identity/matrix-identity.integration.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index 683d91dad..b52376909 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -154,14 +154,14 @@ beforeAll(async () => { // `global-setup.ts`. // // `create()` returns a `Result` — unwrap to the strategy itself, which is what - // `config.strategy` expects (it calls `.getToken()` on it). + // `config.authStrategy` expects (it calls `.getToken()` on it). const federation = OidcFederationStrategy.create(crn, clerkJwtProvider()) if (federation.failure) { throw new Error(`[federation]: ${federation.failure.message}`) } client = await EncryptionV3({ schemas: [schema], - config: { strategy: federation.data }, + config: { authStrategy: federation.data }, }) ops = createEncryptionOperatorsV3(client) db = drizzle({ client: sqlClient }) diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index a6574e14c..6065c2f0d 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -32,7 +32,7 @@ describe('v3 typed client identity-aware operations (live)', () => { if (!crn) { throw new Error('CS_WORKSPACE_CRN must be set for identity tests') } - // `create()` returns a `Result`; unwrap to the strategy `config.strategy` + // `create()` returns a `Result`; unwrap to the strategy `config.authStrategy` // expects. `clerkJwtProvider()` asserts CLERK_MACHINE_TOKEN and re-mints on // every re-federation. const federation = OidcFederationStrategy.create(crn, clerkJwtProvider()) @@ -41,7 +41,7 @@ describe('v3 typed client identity-aware operations (live)', () => { } client = await EncryptionV3({ schemas: [users], - config: { strategy: federation.data }, + config: { authStrategy: federation.data }, }) }, 30000) From fbc051fcd5a2c106ef251c0d8f12e773bf4594cd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 23:25:39 +1000 Subject: [PATCH 06/11] test(stack): add a cross-identity lock-context test (second Clerk machine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the property the single-identity tests can't: a row sealed under identity A must not decrypt under a DIFFERENT identity B. B is a second Clerk machine (a distinct `sub`) in the same Clerk instance, federated via a new `CLERK_MACHINE_TOKEN_B` secret. The test decrypts A's row as A (control, must succeed) and as B (must be denied with a key/identity error, not an infra fault). - clerkJwtProvider(tokenEnvVar='CLERK_MACHINE_TOKEN') is now parameterized and self-asserts its token (throws, never skips), so a second identity is one argument. The now-unused `clerk` requirement is dropped from test-kit env. - CI: the Drizzle job passes CLERK_MACHINE_TOKEN_B (job env only, not the preflight — a missing B fails only the cross-identity test). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .github/workflows/integration-drizzle.yml | 9 ++- packages/stack/integration/helpers/clerk.ts | 25 +++++--- .../identity/lock-context.integration.test.ts | 63 +++++++++++++++++-- packages/test-kit/src/env.ts | 12 +--- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index ae313afc2..f19bef8e9 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -73,10 +73,13 @@ jobs: CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} # The identity suites federate a freshly-minted Clerk M2M JWT into a CTS - # token. Only they read this; the other suites ignore it. If it is unset - # they fail loudly (throw, never skip) rather than taking the whole job - # down, so it is NOT part of the require-cs-secrets preflight. + # token. Only they read these; the other suites ignore them. If one is + # unset the relevant suite fails loudly (throw, never skip) rather than + # taking the whole job down, so they are NOT in the require-cs-secrets + # preflight. `_B` is a SECOND machine (a distinct `sub`) in the same Clerk + # instance, used only by the cross-identity test. CLERK_MACHINE_TOKEN: ${{ secrets.CLERK_MACHINE_TOKEN }} + CLERK_MACHINE_TOKEN_B: ${{ secrets.CLERK_MACHINE_TOKEN_B }} # Job-level env, not a `.env` file: `dotenv/config` does not override an # already-set `process.env`, so these win and no secret is written to disk. DATABASE_URL: ${{ matrix.database-url }} diff --git a/packages/stack/integration/helpers/clerk.ts b/packages/stack/integration/helpers/clerk.ts index 67e66df54..44fc4254e 100644 --- a/packages/stack/integration/helpers/clerk.ts +++ b/packages/stack/integration/helpers/clerk.ts @@ -1,4 +1,3 @@ -import { requireIntegrationEnv } from '@cipherstash/test-kit' import { createClerkClient } from '@clerk/backend' /** @@ -11,17 +10,27 @@ import { createClerkClient } from '@clerk/backend' * in an env var or a CI secret. The only secret is `CLERK_MACHINE_TOKEN`, a * rotatable machine key. * - * The federated identity is the Clerk machine (`CTS|CS|mch_…`), which is stable - * across encrypt and decrypt — enough for the symmetric round-trip and the - * no-context negative path. The cross-identity test needs a SECOND machine - * token (a distinct `sub`) and remains a follow-up. + * The federated identity is the Clerk machine (`CTS|CS|mch_…`), stable across + * encrypt and decrypt. Pass a different `tokenEnvVar` (e.g. a second machine's + * `CLERK_MACHINE_TOKEN_B`) to federate as a DISTINCT identity — that is what the + * cross-identity test uses to prove a row sealed under one machine does not + * decrypt under another. * * The workspace named by `CS_WORKSPACE_CRN` must have this Clerk instance * registered on its OIDC-providers page, or `/api/authorise` rejects the token. */ -export function clerkJwtProvider(): () => Promise { - requireIntegrationEnv(['clerk']) - const machineSecretKey = process.env['CLERK_MACHINE_TOKEN'] as string +export function clerkJwtProvider( + tokenEnvVar = 'CLERK_MACHINE_TOKEN', +): () => Promise { + const machineSecretKey = process.env[tokenEnvVar] + if (!machineSecretKey) { + throw new Error( + `Integration suite cannot run — missing ${tokenEnvVar} (a Clerk machine ` + + 'secret key, ak_...). Its Clerk instance must be registered on the ' + + 'CS_WORKSPACE_CRN workspace OIDC-providers page. This suite FAILS rather ' + + 'than skips: a green skip would hide a real regression.', + ) + } // `createToken` authenticates with `machineSecretKey`; `createClerkClient` // still wants a `secretKey` string at construction that this path never uses. diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index b52376909..c974420ae 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -27,9 +27,10 @@ * insufficient: they drop the context identically on both sides, so they stay * green even if it were ignored entirely. * - * A cross-identity non-match (sealed under A, queried under B) still needs a - * second JWT with a different `sub` — a lock context only names the claim while - * ZeroKMS resolves its value from the authenticating token. Tracked separately. + * The cross-identity non-match (sealed under A, decrypted under B) is covered by + * the final test: it federates a SECOND Clerk machine (`CLERK_MACHINE_TOKEN_B`, + * a distinct `sub`) and asserts B cannot read A's row, with A reading it as the + * control. */ import { OidcFederationStrategy } from '@cipherstash/auth' import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' @@ -240,11 +241,11 @@ describe('v3 drizzle operators with lock context (live pg)', () => { // assumed they were and asserted `[]`; this is corrected to the OBSERVED // behaviour, with the real boundary proven by the decryption test. // - // A true CROSS-identity proof (sealed under A, queried under B) needs a + // The true CROSS-identity proof (sealed under A, decrypted under B) needs a // SECOND machine identity with a different `sub`; the lock context only names // the claim (`['sub']`) while ZeroKMS resolves its value from the - // authenticating token. Only one Clerk machine token is wired up, so that - // remains a follow-up. + // authenticating token. That is the final test in this suite, federating + // `CLERK_MACHINE_TOKEN_B`. it('an eq matches the same row with or without a lock context (search is not identity-bound)', async () => { const bound = await ops.eq(secretTable.secret, SECRET_A, { lockContext: IDENTITY_CLAIM as never, @@ -300,4 +301,54 @@ describe('v3 drizzle operators with lock context (live pg)', () => { expect(message).toMatch(IDENTITY_DENIAL) expect(message).not.toMatch(INFRA_FAULT) }, 30000) + + // The true cross-identity proof: same `['sub']` claim, a DIFFERENT + // authenticating machine. Everything above uses one identity, so it cannot + // tell "bound to A" from "bound to nothing" — this can. Machine B must be a + // distinct machine (its own `sub`) in the same Clerk instance registered on + // the workspace; `clerkJwtProvider('CLERK_MACHINE_TOKEN_B')` throws if that + // token is unset, so this fails loudly rather than skipping. + it('a row sealed under identity A does not decrypt under a different identity (B)', async () => { + const crn = process.env.CS_WORKSPACE_CRN as string + const federationB = OidcFederationStrategy.create( + crn, + clerkJwtProvider('CLERK_MACHINE_TOKEN_B'), + ) + if (federationB.failure) { + throw new Error(`[federation B]: ${federationB.failure.message}`) + } + const clientB = await EncryptionV3({ + schemas: [schema], + config: { authStrategy: federationB.data }, + }) + + const [row] = await sqlClient.unsafe>( + `SELECT secret::jsonb AS value FROM ${TABLE_NAME} + WHERE test_run_id = $1 AND row_key = $2`, + [RUN, ROW_A], + ) + expect(row).toBeDefined() + + // Control: identity A reads its own row. If this is denied, A's federation + // or the fixture is broken and the cross-identity assertion below would + // "pass" for the wrong reason. + const asA = await decryptOutcome(() => + client.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM), + ) + expect( + asA.denied, + `identity A must read its own row (got: ${asA.message})`, + ).toBe(false) + + // Cross-identity: B names the same `['sub']` claim, but its value is B's + // machine, so ZeroKMS cannot reproduce A's key. + const asB = await decryptOutcome(() => + clientB.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM), + ) + expect(asB.denied, 'identity B must NOT decrypt a row sealed under A').toBe( + true, + ) + expect(asB.message).toMatch(IDENTITY_DENIAL) + expect(asB.message).not.toMatch(INFRA_FAULT) + }, 30000) }) diff --git a/packages/test-kit/src/env.ts b/packages/test-kit/src/env.ts index 7fc7b4327..cb68fe83d 100644 --- a/packages/test-kit/src/env.ts +++ b/packages/test-kit/src/env.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' * to fix it. */ -export type Requirement = 'cipherstash' | 'database' | 'pgrest' | 'clerk' +export type Requirement = 'cipherstash' | 'database' | 'pgrest' /** Every variable protect-ffi's `AutoStrategy` needs to authenticate from the environment. */ const CS_VARS = [ @@ -87,16 +87,6 @@ const HINTS: Record string | null> = { : 'PGRST_URL. The Supabase adapter speaks PostgREST, not Postgres:\n' + ' docker compose -f local/docker-compose.supabase.yml up -d --wait\n' + ' export PGRST_URL=http://localhost:55430', - - clerk: () => - process.env['CLERK_MACHINE_TOKEN'] - ? null - : 'CLERK_MACHINE_TOKEN. The identity / lock-context suites federate a\n' + - ' freshly-minted Clerk machine (M2M) JWT into a CTS token, so they need\n' + - " the machine's secret key (ak_...):\n" + - ' export CLERK_MACHINE_TOKEN=ak_...\n' + - ' The CS_WORKSPACE_CRN workspace must have that Clerk instance registered\n' + - ' on its OIDC-providers page.', } /** From b3dadc154fc58af017a2f333bb2850b57c550a33 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 11 Jul 2026 23:41:01 +1000 Subject: [PATCH 07/11] test(stack): address review comments on the identity suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - matrix-identity header: the suite IS in CI now (integration-drizzle's suite globs include integration/identity/**) — correct the stale "not yet in CI" note (Copilot). - drop the `as never` on decryptModel's lock-context arg: it is typed `LockContextInput` = `LockContext | { identityClaim }`, so the plain claim object fits without a cast (Copilot). coderdan's note (clerkJwtProvider should throw on a missing token) was already addressed by the parameterized rewrite: it throws with an actionable message when the token env var is unset, replacing the old `as string` cast. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../identity/matrix-identity.integration.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index 6065c2f0d..a026411fa 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -8,9 +8,9 @@ * CTS token via `OidcFederationStrategy` and binds the lock context to `sub` * (resolved by ZeroKMS from the token — here, the Clerk machine identity). * - * Lives under `integration/identity/`, which is not yet in the CI suite globs - * (see the integration workflows): it THROWS rather than skips without - * `CLERK_MACHINE_TOKEN`, and joins CI once that workspace/secret is wired up. + * Runs in CI on the Drizzle integration job — `integration/identity/**` is in + * its `CS_IT_SUITE` globs. Like every integration suite it THROWS rather than + * skips when unconfigured: `clerkJwtProvider()` requires `CLERK_MACHINE_TOKEN`. */ import { OidcFederationStrategy } from '@cipherstash/auth' import { unwrapResult } from '@cipherstash/test-kit' @@ -53,9 +53,10 @@ describe('v3 typed client identity-aware operations (live)', () => { ) expect(encrypted.email).toHaveProperty('c') - // decryptModel takes the lock context as a positional 3rd arg. + // decryptModel takes the lock context as a positional 3rd arg + // (`LockContextInput` = `LockContext | { identityClaim }`). const decrypted = unwrapResult( - await client.decryptModel(encrypted, users, LOCK as never), + await client.decryptModel(encrypted, users, LOCK), ) expect(decrypted.email).toBe('ada@example.com') }, 30000) From cfef834a8e4a60d38fed360cd47ec082f0910754 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 12 Jul 2026 16:46:18 +1000 Subject: [PATCH 08/11] test(stack): harden the identity suites' negative paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the Clerk-federated identity integration suites: - matrix-identity had only a symmetric model-path round-trip (same lock context on encrypt and decrypt), so it stayed green even if decryptModel ignored the context entirely. Add a negative test: a model sealed under an identity must NOT decrypt without its context, reading the denial off `Result.failure` and pinning it to a key-derivation denial (not an outage). - The cross-identity test asserted B's denial with the broad IDENTITY_DENIAL matcher, which also matches an authorization rejection — so a machine B that isn't registered on the workspace would pass the test without ever exercising the key-derivation boundary. Pin it to KEY_DENIAL, since B authenticates as a valid machine and the denial must surface at key derivation. --- .../identity/lock-context.integration.test.ts | 12 ++++- .../matrix-identity.integration.test.ts | 47 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index c974420ae..8ef131fa5 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -341,14 +341,22 @@ describe('v3 drizzle operators with lock context (live pg)', () => { ).toBe(false) // Cross-identity: B names the same `['sub']` claim, but its value is B's - // machine, so ZeroKMS cannot reproduce A's key. + // machine, so ZeroKMS derives a DIFFERENT key and refuses — a genuine + // key-derivation denial (`Failed to retrieve key`). Pin to KEY_DENIAL, NOT + // the broad IDENTITY_DENIAL: B is a valid, registered machine, so it + // authenticates fine and the denial must surface at key derivation. Were B + // instead rejected at the authorization layer (e.g. its machine not + // registered on the workspace), that "unauthorized" message would satisfy + // IDENTITY_DENIAL and the test would pass GREEN without ever exercising the + // identity boundary — proving "B can't authenticate", not "B can't reproduce + // A's key". const asB = await decryptOutcome(() => clientB.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM), ) expect(asB.denied, 'identity B must NOT decrypt a row sealed under A').toBe( true, ) - expect(asB.message).toMatch(IDENTITY_DENIAL) + expect(asB.message).toMatch(KEY_DENIAL) expect(asB.message).not.toMatch(INFRA_FAULT) }, 30000) }) diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index a026411fa..df1d7d325 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -1,6 +1,8 @@ /** * Live identity-aware coverage for the v3 typed client: a lock-context - * encrypt→decrypt round-trip through the MODEL path, plus audit metadata. + * encrypt→decrypt round-trip through the MODEL path, the negative path (a model + * sealed under an identity must NOT decrypt without its context), plus audit + * metadata. * * Kept separate from `matrix-lock-context.test.ts`, which mocks * `@cipherstash/protect-ffi` file-wide — a mock would neutralize a "live" @@ -24,6 +26,14 @@ const users = encryptedTable('v3_identity_live_users', { const LOCK = { identityClaim: ['sub'] } +// A genuine key-derivation denial — ZeroKMS cannot reproduce the identity-bound +// key without the context the model was sealed under — versus a transport/outage +// fault that must never be read as a denial. Mirrors the matchers the Drizzle +// lock-context suite pins to. +const KEY_DENIAL = /failed to retrieve key/i +const INFRA_FAULT = + /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|timed? ?out|network error/i + describe('v3 typed client identity-aware operations (live)', () => { let client: Awaited>> @@ -61,6 +71,41 @@ describe('v3 typed client identity-aware operations (live)', () => { expect(decrypted.email).toBe('ada@example.com') }, 30000) + // NEGATIVE PATH. The round-trip above applies the SAME context on both sides, + // so it stays green even if the model path ignored the lock context entirely. + // This is the assertion that fails if it does: a model sealed under an identity + // must NOT decrypt without its context. `decryptModel` reports denial as a + // `Result.failure` rather than throwing, so read it off the result (and count + // a throw as denial too) instead of unwrapping — a bare `unwrapResult` would + // turn a held boundary into a thrown error and mask WHY it was denied. + it('a model sealed with a lock context does NOT decrypt without it', async () => { + const encrypted = unwrapResult( + await client + .encryptModel({ email: 'ada@example.com' }, users) + .withLockContext(LOCK), + ) + + let denied = false + let message = '' + try { + const result: { failure?: { message: string } } = + await client.decryptModel(encrypted, users) + denied = Boolean(result.failure) + message = result.failure?.message ?? '' + } catch (error) { + denied = true + message = (error as Error).message + } + + expect( + denied, + `model decrypt must be denied without its context (got: ${message})`, + ).toBe(true) + // A real key-derivation denial, not an outage masquerading as one. + expect(message).toMatch(KEY_DENIAL) + expect(message).not.toMatch(INFRA_FAULT) + }, 30000) + it('accepts .audit({ metadata }) on the encrypt path and still round-trips', async () => { const encrypted = unwrapResult( await client From c3a285392649d17b0d6e7c8b2d158cff4d4240ae Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 12 Jul 2026 22:15:39 +1000 Subject: [PATCH 09/11] fix(stack): close review findings on the combined identity/reorg PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - integration vitest config: guard against a CS_IT_SUITE glob that resolves to nothing. A renamed/moved integration directory (while the workflow glob still points at the old path) would otherwise drop those suites from CI with the board staying green — the silent-zero-coverage hole the deleted live-coverage-guard used to backstop. Each glob's literal directory prefix must exist; `passWithNoTests: false` covers the fully-empty run. (task #12) - matrix-identity: split the identity-AGNOSTIC `.audit({ metadata })` test into its own describe with a plain (non-federated) client. It was sharing the federation beforeAll, so a Clerk outage / rotated CLERK_MACHINE_TOKEN would fail a test that exercises no identity behaviour. Verified: the audit test passes with no Clerk token while the federated tests require one. - finish the unwrapResult de-dup: lock-context and relational suites now import it from @cipherstash/test-kit instead of each re-declaring a local `unwrap`. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../drizzle-v3/relational.integration.test.ts | 12 ++---- .../identity/lock-context.integration.test.ts | 11 ++---- .../matrix-identity.integration.test.ts | 14 +++++++ packages/stack/integration/vitest.config.ts | 38 +++++++++++++++++++ 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/stack/integration/drizzle-v3/relational.integration.test.ts b/packages/stack/integration/drizzle-v3/relational.integration.test.ts index e555b6a92..83f6a1587 100644 --- a/packages/stack/integration/drizzle-v3/relational.integration.test.ts +++ b/packages/stack/integration/drizzle-v3/relational.integration.test.ts @@ -28,6 +28,7 @@ import { eqlTypeSlug as slug, sortedKeysFor as sortedKeysForKit, typedEntries, + unwrapResult, V3_MATRIX, } from '@cipherstash/test-kit' import { @@ -190,11 +191,6 @@ async function selectRowKeys(condition: SQL | undefined): Promise { return rows.map((row) => row.rowKey) } -function unwrap(result: { data?: T; failure?: { message: string } }): T { - if (result.failure) throw new Error(result.failure.message) - return result.data as T -} - function encryptedInsertRows(): MatrixPlainRow[] { return ROWS.map((rowKey) => { const row: MatrixPlainRow = { @@ -260,7 +256,7 @@ beforeAll(async () => { ) `) - const encryptedRows = unwrap( + const encryptedRows = unwrapResult( await client.bulkEncryptModels(encryptedInsertRows(), schema), ) await db.insert(matrixTable).values(encryptedRows) @@ -276,7 +272,7 @@ beforeAll(async () => { // ROW_B exists so the filter proofs below have a row they must EXCLUDE. On a // one-row table `gt(balance, 0n)` returning every row is indistinguishable // from it returning the right row. - const bigintRows = unwrap( + const bigintRows = unwrapResult( await client.bulkEncryptModels( [ { @@ -578,7 +574,7 @@ describe('v3 drizzle — relational, needle guards, pagination', () => { ) expect(encrypted).toHaveLength(1) - const decrypted = unwrap( + const decrypted = unwrapResult( await client.decryptModel(encrypted[0], bigintSchema), ) expect(decrypted.balance).toBe(BIGINT_BALANCE) diff --git a/packages/stack/integration/identity/lock-context.integration.test.ts b/packages/stack/integration/identity/lock-context.integration.test.ts index 8ef131fa5..ad4fc52d5 100644 --- a/packages/stack/integration/identity/lock-context.integration.test.ts +++ b/packages/stack/integration/identity/lock-context.integration.test.ts @@ -33,7 +33,7 @@ * control. */ import { OidcFederationStrategy } from '@cipherstash/auth' -import { databaseUrl, V3_MATRIX } from '@cipherstash/test-kit' +import { databaseUrl, unwrapResult, V3_MATRIX } from '@cipherstash/test-kit' import { and, asc as drizzleAsc, eq as drizzleEq, type SQL } from 'drizzle-orm' import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' @@ -75,11 +75,6 @@ let client: Awaited> let ops: ReturnType let db: ReturnType -function unwrap(result: { data?: T; failure?: { message: string } }): T { - if (result.failure) throw new Error(result.failure.message) - return result.data as T -} - /** * The outcome of a decrypt attempt that is EXPECTED to be denied. `decrypt` * reports denial as a `Result.failure` rather than throwing, so a bare `await` @@ -177,7 +172,7 @@ beforeAll(async () => { `) // Seed BOTH rows bound to the same lock context. - const encryptedRows = unwrap>>( + const encryptedRows = unwrapResult>>( await client .bulkEncryptModels( [ @@ -214,7 +209,7 @@ describe('v3 drizzle operators with lock context (live pg)', () => { ) expect(row).toBeDefined() - const decrypted = unwrap( + const decrypted = unwrapResult( await client.decrypt(row.value as never).withLockContext(IDENTITY_CLAIM), ) expect(decrypted).toBe(SECRET_A) diff --git a/packages/stack/integration/identity/matrix-identity.integration.test.ts b/packages/stack/integration/identity/matrix-identity.integration.test.ts index df1d7d325..a20893929 100644 --- a/packages/stack/integration/identity/matrix-identity.integration.test.ts +++ b/packages/stack/integration/identity/matrix-identity.integration.test.ts @@ -105,6 +105,20 @@ describe('v3 typed client identity-aware operations (live)', () => { expect(message).toMatch(KEY_DENIAL) expect(message).not.toMatch(INFRA_FAULT) }, 30000) +}) + +// The `.audit({ metadata })` path is identity-AGNOSTIC — it attaches audit +// metadata to an encrypt, with no lock context and no `sub` binding. It needs +// only CS credentials, so it gets its own suite with a plain (non-federated) +// client. Coupling it to the Clerk federation `beforeAll` above would fail this +// test on a Clerk outage or a rotated `CLERK_MACHINE_TOKEN` — for behaviour it +// never exercises. +describe('v3 typed client audit metadata (live)', () => { + let client: Awaited>> + + beforeAll(async () => { + client = await EncryptionV3({ schemas: [users] }) + }, 30000) it('accepts .audit({ metadata }) on the encrypt path and still round-trips', async () => { const encrypted = unwrapResult( diff --git a/packages/stack/integration/vitest.config.ts b/packages/stack/integration/vitest.config.ts index 3c226878a..c252f1e47 100644 --- a/packages/stack/integration/vitest.config.ts +++ b/packages/stack/integration/vitest.config.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' import { sharedAlias } from '../../../vitest.shared' @@ -19,6 +20,8 @@ import { sharedAlias } from '../../../vitest.shared' * Scoping matters: the Drizzle suites talk straight to Postgres and the Supabase * suites need PostgREST, so a job running both would have to provision both. */ +const ROOT = resolve(__dirname, '..') + const SUITE_GLOBS = ( process.env['CS_IT_SUITE'] ?? 'integration/**/*.integration.test.ts' ) @@ -26,6 +29,37 @@ const SUITE_GLOBS = ( .map((glob) => glob.trim()) .filter(Boolean) +/** + * Guard against a glob that resolves to nothing — the silent-zero-coverage hole + * the deleted `live-coverage-guard.test.ts` used to backstop. A directory + * renamed/moved (or a typo) while the workflow's `CS_IT_SUITE` still points at + * the old path makes vitest collect the OTHER globs, pass, and drop those suites + * with no signal — `passWithNoTests` only catches the all-empty case, and the + * no-skips reporter only sees files that were collected. + * + * The literal directory prefix of each glob (everything before the first glob + * metacharacter) must exist. This catches the whole-directory rename directly; + * `passWithNoTests: false` (below) still covers a fully-empty run. + */ +for (const glob of SUITE_GLOBS) { + const literalPrefix = glob + .split('/') + .slice( + 0, + glob.split('/').findIndex((seg) => /[*?{}[\]]/.test(seg)), + ) + .join('/') + // No metacharacter at all → the glob is a literal file path; check it whole. + const target = /[*?{}[\]]/.test(glob) ? literalPrefix : glob + if (target && !existsSync(resolve(ROOT, target))) { + throw new Error( + `CS_IT_SUITE glob "${glob}" points at "${target}", which does not exist. ` + + 'A renamed/moved integration directory would otherwise drop its suites ' + + 'from CI silently. Fix the glob (or the directory) so the suites run.', + ) + } +} + export default defineConfig({ resolve: { alias: { @@ -81,6 +115,10 @@ export default defineConfig({ */ silent: 'passed-only', + // A fully-empty run is a failure, not a pass: paired with the per-glob + // directory guard above, a mistyped/renamed suite path can never go green. + passWithNoTests: false, + // Fail the run if anything is skipped. A skipped test reads exactly like a // passing one, and every silent hole this suite has found took that shape. reporters: ['default', resolve(__dirname, 'no-skips-reporter.ts')], From 7f89201bfb05ff34c4656a40eb3692629a3a995f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 12 Jul 2026 22:21:26 +1000 Subject: [PATCH 10/11] test(stack): scope the schema-pg index assertion to the run's own schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the `schemaname` filter entirely (to fix the environment-dependence of hardcoding `public`) let the `indexname IN (...)` check match across ALL schemas, so a same-named index left in another schema by an earlier run on a shared DB could false-pass. Derive the schema from `pg_tables` for THIS run's table instead — environment-independent AND scoped to the run's own table. Verified on both the postgres and supabase variants. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../shared/schema-pg.integration.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/stack/integration/shared/schema-pg.integration.test.ts b/packages/stack/integration/shared/schema-pg.integration.test.ts index 9d0d2cfc5..a034411d6 100644 --- a/packages/stack/integration/shared/schema-pg.integration.test.ts +++ b/packages/stack/integration/shared/schema-pg.integration.test.ts @@ -280,16 +280,22 @@ describe('eql_v3 text_search postgres integration', () => { ON protect_ci_v3_text_search USING btree (eql_v3.ord_term(email)) ` - // No `schemaname` filter: the table is created unqualified, so it lands in + // Scope to the schema THIS run's table actually landed in, rather than + // hardcoding `public`. The table is created unqualified, so it lands in // whatever `search_path` puts first — `public` on a fresh CI database, but // the owning role's own schema (`cipherstash`) on a database whose - // search_path is `"$user", public`. The unique index names identify the - // indexes on their own; pinning `schemaname = 'public'` made the assertion - // environment-dependent. + // search_path is `"$user", public`; a hardcoded `schemaname = 'public'` made + // the assertion environment-dependent. Deriving the schema from `pg_tables` + // keeps it environment-independent AND avoids a false pass from a same-named + // index left in another schema by an earlier run on a shared/persistent DB. const indexes = await sql<{ indexname: string; indexdef: string }[]>` SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'protect_ci_v3_text_search' + AND schemaname = ( + SELECT schemaname FROM pg_tables + WHERE tablename = 'protect_ci_v3_text_search' + ) AND indexname IN ( 'protect_ci_v3_text_search_email_eq_idx', 'protect_ci_v3_text_search_email_match_idx', From 46db62ab5ceb468270648af7068aed1200a3f225 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 12 Jul 2026 22:25:38 +1000 Subject: [PATCH 11/11] test(stack): assert clerkJwtProvider throws when its token env var is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the review gap: the identity suites' "fail rather than skip" contract rests on clerkJwtProvider throwing when CLERK_MACHINE_TOKEN is unset, but nothing asserted it — a weakening to a skip/undefined would take the identity suite dark while CI stayed green. The throw fires before createClerkClient, so this is a credential-free unit test. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../stack/__tests__/clerk-provider.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/stack/__tests__/clerk-provider.test.ts diff --git a/packages/stack/__tests__/clerk-provider.test.ts b/packages/stack/__tests__/clerk-provider.test.ts new file mode 100644 index 000000000..597e2e8af --- /dev/null +++ b/packages/stack/__tests__/clerk-provider.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { clerkJwtProvider } from '../integration/helpers/clerk' + +/** + * `clerkJwtProvider`'s "FAIL rather than skip" guard is the load-bearing half of + * the identity suites' no-silent-skip contract: if it were ever weakened to a + * skip or a silent `undefined`, the entire identity suite would go dark while CI + * stayed green. The throw is pure logic — it fires before `createClerkClient`, + * so it needs no live Clerk and belongs here in the credential-free unit suite + * rather than the integration config (which only globs `*.integration.test.ts`). + */ +describe('clerkJwtProvider', () => { + const saved = process.env.CLERK_MACHINE_TOKEN + const savedB = process.env.CLERK_MACHINE_TOKEN_B + afterEach(() => { + if (saved === undefined) delete process.env.CLERK_MACHINE_TOKEN + else process.env.CLERK_MACHINE_TOKEN = saved + if (savedB === undefined) delete process.env.CLERK_MACHINE_TOKEN_B + else process.env.CLERK_MACHINE_TOKEN_B = savedB + }) + + it('throws (does not skip) when the default machine token env var is unset', () => { + delete process.env.CLERK_MACHINE_TOKEN + expect(() => clerkJwtProvider()).toThrow(/missing CLERK_MACHINE_TOKEN/) + }) + + it('names the custom env var it was handed', () => { + delete process.env.CLERK_MACHINE_TOKEN_B + expect(() => clerkJwtProvider('CLERK_MACHINE_TOKEN_B')).toThrow( + /CLERK_MACHINE_TOKEN_B/, + ) + }) +})