Skip to content

Commit 8f9df8b

Browse files
committed
test(integration): port the wire suite, drop its gate, wire up CI
Completes the Supabase half of PR1. 655 tests now run in one job against real ZeroKMS ciphertext, a real PostgREST, and supabase/postgres. - `supabase-v3-pgrest-live.test.ts` moves to `integration/supabase/wire. integration.test.ts`, de-gated. It is NOT superseded by the real-crypto matrix: it uniquely proves the 23514 rejection of a narrowed encryptQuery term (a shape the adapter cannot produce, so it must be hand-built), dense PostgREST parse edges, plaintext-passthrough containment, and the grants as `anon`. Its stubbed ZeroKMS is the point — it needs no credentials. Its `installEqlV3IfNeeded` + `SUPABASE_PERMISSIONS_SQL_V3` calls are gone: `globalSetup` installs both by running the real `stash eql install --eql-version 3 --supabase --direct`. Re-applying them here would only have tested a hand-rolled approximation of the installer. - `LIVE_SUPABASE_PGREST_ENABLED` / `describeLiveSupabasePgrest` are deleted along with the `live-coverage-guard` assertion that policed them. The guard existed because a false gate meant a silent whole-suite skip on a green job; a suite that throws needs no such guard. The remaining `LIVE_*` gates and their assertions stay — their suites have not moved. - `tests.yml` loses the PostgREST step and `PGRST_URL`. The moved suite was its only consumer, so the unit job no longer starts a container it does not need. - `integration-supabase.yml` runs the suites on `supabase/postgres` + PostgREST, fork-PR gated, secrets passed as job env rather than written to a `.env`. `CS_IT_SUITE` scopes the run to the suites this database serves, so the Drizzle suites (plain Postgres, no PostgREST) can have their own job without either provisioning the other's dependencies. Verified: 655 pass / 8 skipped under the exact CI invocation; `pnpm test` still runs 1554 unit tests and zero integration tests; both typechecks clean.
1 parent 48618a4 commit 8f9df8b

9 files changed

Lines changed: 133 additions & 104 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: Integration — Supabase (EQL v3)
2+
3+
# Real ZeroKMS ciphertext, a real PostgREST, and `supabase/postgres` — the only
4+
# job that proves the Supabase v3 adapter's queries return the right rows. The
5+
# unit suites drive a mock that records strings; they cannot.
6+
#
7+
# Separate from `tests.yml` on purpose: these suites need CipherStash credentials
8+
# and a database, and they THROW rather than skip when unconfigured. Keeping them
9+
# out of the unit job is what lets `pnpm test` stay runnable with neither.
10+
11+
on:
12+
push:
13+
branches: [main]
14+
paths:
15+
- 'packages/stack/src/supabase/**'
16+
- 'packages/stack/src/eql/v3/**'
17+
- 'packages/stack/integration/**'
18+
- 'packages/test-kit/**'
19+
- 'packages/cli/src/installer/**'
20+
- 'local/docker-compose.supabase.yml'
21+
- 'local/supabase-init.sql'
22+
- '.github/workflows/integration-supabase.yml'
23+
- '.github/actions/integration-setup/**'
24+
pull_request:
25+
branches: ['**']
26+
paths:
27+
- 'packages/stack/src/supabase/**'
28+
- 'packages/stack/src/eql/v3/**'
29+
- 'packages/stack/integration/**'
30+
- 'packages/test-kit/**'
31+
- 'packages/cli/src/installer/**'
32+
- 'local/docker-compose.supabase.yml'
33+
- 'local/supabase-init.sql'
34+
- '.github/workflows/integration-supabase.yml'
35+
- '.github/actions/integration-setup/**'
36+
37+
jobs:
38+
integration:
39+
name: Supabase v3 integration (db=${{ matrix.db }})
40+
runs-on: blacksmith-4vcpu-ubuntu-2404
41+
# Fork PRs have no secrets. Skip cleanly rather than fail loudly on something
42+
# the contributor cannot fix — `tests.yml` still gives them a green signal.
43+
if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
44+
45+
strategy:
46+
# A one-element matrix, not a cross-product: the Supabase adapter only ever
47+
# runs against the Supabase variant. Kept as a matrix so adding a second
48+
# database is a one-line change.
49+
matrix:
50+
db: [supabase]
51+
52+
env:
53+
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
54+
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
55+
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
56+
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
57+
# Job-level env, not a `.env` file: `dotenv/config` does not override an
58+
# already-set `process.env`, so these win and no secret is written to disk.
59+
#
60+
# `postgres` here is deliberately NOT a superuser on this image — that is
61+
# what makes the EQL install, the grants, and the ORE opclass skip behave
62+
# as they do on a real Supabase project.
63+
DATABASE_URL: postgres://postgres:password@localhost:55433/postgres
64+
PGRST_URL: http://localhost:55430
65+
# Scoped to the suites this database serves. The Drizzle suites talk to
66+
# plain Postgres and get their own job; the harness and bloom suites are
67+
# adapter-agnostic and run here because a database is already up.
68+
CS_IT_SUITE: >-
69+
integration/supabase/**/*.integration.test.ts,
70+
integration/harness.integration.test.ts,
71+
integration/match-bloom.integration.test.ts
72+
73+
steps:
74+
- uses: actions/checkout@v6
75+
- uses: ./.github/actions/integration-setup
76+
77+
# Fast pre-flight: fail in seconds if a secret was rotated or cleared,
78+
# before paying for the ~2 GB supabase/postgres pull. The in-test
79+
# `requireIntegrationEnv` is the correctness guarantee; this is the cheap one.
80+
- name: Require CipherStash secrets
81+
uses: ./.github/actions/require-cs-secrets
82+
with:
83+
workspace-crn: ${{ secrets.CS_WORKSPACE_CRN }}
84+
client-id: ${{ secrets.CS_CLIENT_ID }}
85+
client-key: ${{ secrets.CS_CLIENT_KEY }}
86+
client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
87+
88+
- name: Start ${{ matrix.db }}
89+
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml up -d --wait
90+
91+
# `globalSetup` installs EQL v3 by shelling out to the real
92+
# `stash eql install --eql-version 3 --supabase --direct`, so an installer
93+
# regression fails here rather than hiding behind a test-only SQL apply.
94+
- name: Supabase v3 integration suites
95+
run: pnpm --filter @cipherstash/stack run test:integration
96+
97+
- name: Stop ${{ matrix.db }}
98+
if: always()
99+
run: docker compose -f local/docker-compose.${{ matrix.db }}.yml down -v

.github/workflows/tests.yml

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -103,34 +103,6 @@ jobs:
103103
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env
104104
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env
105105
106-
# PostgREST for `supabase-v3-pgrest-live.test.ts` — the only suite that
107-
# executes the supabase v3 adapter against a real server rather than a
108-
# mock that records strings.
109-
#
110-
# Started as a STEP, not a `services:` entry. The role it connects as
111-
# (`authenticator`, a non-superuser member of `anon`) does not exist in
112-
# the postgres image, and service containers all start before the repo is
113-
# checked out — so there is no point at which a `services:` PostgREST
114-
# could find it. A step runs after the roles exist, deterministically.
115-
#
116-
# `PGRST_DB_ANON_ROLE: anon` is the whole point: pointing it at the DB
117-
# owner would make every grant check pass vacuously.
118-
- name: Start PostgREST
119-
run: |
120-
PGPASSWORD=password psql -h localhost -U cipherstash -d cipherstash \
121-
-v ON_ERROR_STOP=1 -f ./local/postgrest-roles.sql
122-
docker run -d --name postgrest --network host \
123-
-e PGRST_DB_URI="postgres://authenticator:authpass@localhost:5432/cipherstash" \
124-
-e PGRST_DB_SCHEMAS=public \
125-
-e PGRST_DB_ANON_ROLE=anon \
126-
-e PGRST_DB_CHANNEL_ENABLED=true \
127-
postgrest/postgrest:v12.2.12
128-
for i in $(seq 1 30); do
129-
curl -sf -o /dev/null http://localhost:3000/ && exit 0
130-
sleep 1
131-
done
132-
echo "PostgREST did not become ready"; docker logs postgrest; exit 1
133-
134106
- name: Create .env file in ./packages/stack/
135107
run: |
136108
touch ./packages/stack/.env
@@ -139,7 +111,6 @@ jobs:
139111
echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/stack/.env
140112
echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env
141113
echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env
142-
echo "PGRST_URL=http://localhost:3000" >> ./packages/stack/.env
143114
144115
- name: Create .env file in ./packages/protect-dynamodb/
145116
run: |

packages/stack/__tests__/helpers/live-gate.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,8 @@ export const LIVE_PG_ENABLED = Boolean(process.env.DATABASE_URL)
5151

5252
export const describeLivePgOnly = LIVE_PG_ENABLED ? describe : describe.skip
5353

54-
/**
55-
* True when a real PostgREST is reachable AND a `DATABASE_URL` is configured.
56-
*
57-
* The supabase v3 adapter talks to PostgREST, not to Postgres — the aliasing
58-
* `prop:db_name::jsonb` casts, the `cs` containment mapping, the quoted
59-
* envelopes inside `or=(…)`, and the full storage envelope every `public.*`
60-
* domain CHECK must accept are all things only a real server can execute.
61-
* Everything else in the repo asserts them as strings against a mock.
62-
*
63-
* NO CipherStash creds: the suite builds structurally-valid envelopes itself
64-
* (the domain CHECKs are structural — `v`/`i`/`c` plus the domain's index
65-
* terms), so it can run wherever the DB-only suites run. It proves the WIRE and
66-
* the GRANTS. Cryptographic round-tripping is `drizzle-v3/operators-live-pg`'s
67-
* job and needs the credentials.
68-
*/
69-
export const LIVE_SUPABASE_PGREST_ENABLED =
70-
Boolean(process.env.PGRST_URL) && LIVE_PG_ENABLED
71-
72-
export const describeLiveSupabasePgrest = LIVE_SUPABASE_PGREST_ENABLED
73-
? describe
74-
: describe.skip
54+
// The PostgREST gate (`LIVE_SUPABASE_PGREST_ENABLED` / `describeLiveSupabasePgrest`)
55+
// is gone. Its only consumer, `supabase-v3-pgrest-live.test.ts`, moved to
56+
// `integration/supabase/wire.integration.test.ts`, which THROWS on missing
57+
// configuration instead of skipping — so no gate, and nothing for the
58+
// coverage guard to assert.

packages/stack/__tests__/live-coverage-guard.test.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ import {
5757
LIVE_EQL_V3_PG_ENABLED,
5858
LIVE_LOCK_CONTEXT_ENABLED,
5959
LIVE_PG_ENABLED,
60-
LIVE_SUPABASE_PGREST_ENABLED,
6160
} from './helpers/live-gate'
6261

6362
// GitHub Actions always sets CI=true; treat any truthy CI as "must run live".
@@ -125,26 +124,6 @@ describe('live-coverage guard', () => {
125124
},
126125
)
127126

128-
it.runIf(IN_CI)(
129-
'CI must have PGRST_URL so the live supabase PostgREST suite does not silently skip',
130-
() => {
131-
expect(
132-
LIVE_SUPABASE_PGREST_ENABLED,
133-
'CI must run the live supabase PostgREST suite — ' +
134-
'`LIVE_SUPABASE_PGREST_ENABLED` is false. This needs a ' +
135-
'`DATABASE_URL` AND a `PGRST_URL` pointing at the pinned ' +
136-
'`postgrest/postgrest` service in .github/workflows/tests.yml (no ' +
137-
'CS_* creds — the domain CHECKs are structural). It is ' +
138-
'the ONLY suite that executes the adapter against a real PostgREST — ' +
139-
'the `prop:db_name::jsonb` aliasing selects, the `cs` containment ' +
140-
'mapping, and the full-envelope filter operands that every `public.*` ' +
141-
'domain CHECK must accept. Everything else asserts those as strings ' +
142-
'against a mock, so a false value here means the wire encoding is ' +
143-
'unproven while CI stays green.',
144-
).toBe(true)
145-
},
146-
)
147-
148127
// Local dev with no creds: nothing to assert. Keep at least one always-run
149128
// assertion so the file is never reported as fully empty/pending.
150129
it('is always collected (guard file runs outside every live gate)', () => {

packages/stack/__tests__/helpers/pgrest.ts renamed to packages/stack/integration/helpers/pgrest.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export function makePostgrestClient(): SupabaseClientLike {
1818
const url = process.env.PGRST_URL
1919
if (!url) {
2020
throw new Error(
21-
'PGRST_URL is not set — makePostgrestClient() must only be called behind `describeLiveSupabasePgrest`',
21+
'PGRST_URL is not set. The integration `globalSetup` asserts it before any ' +
22+
'suite is collected, so reaching this means makePostgrestClient() was ' +
23+
'called outside the integration harness.',
2224
)
2325
}
2426
return new PostgrestClient(url) as unknown as SupabaseClientLike

packages/stack/integration/supabase/adapter.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ import {
99
import postgres from 'postgres'
1010
import { encryptedTable } from '@/eql/v3'
1111
import { encryptedSupabaseV3 } from '@/supabase'
12-
import {
13-
makePostgrestClient,
14-
reloadSchemaCache,
15-
} from '../../__tests__/helpers/pgrest'
12+
import { makePostgrestClient, reloadSchemaCache } from '../helpers/pgrest'
1613

1714
/**
1815
* The Supabase v3 adapter under real ZeroKMS ciphertext.

packages/stack/integration/supabase/select-alias.integration.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import postgres from 'postgres'
33
import { afterAll, beforeAll, expect, it } from 'vitest'
44
import { encryptedTable, types } from '@/eql/v3'
55
import { encryptedSupabaseV3 } from '@/supabase'
6-
import {
7-
makePostgrestClient,
8-
reloadSchemaCache,
9-
} from '../../__tests__/helpers/pgrest'
6+
import { makePostgrestClient, reloadSchemaCache } from '../helpers/pgrest'
107

118
/**
129
* `Date` reconstruction across every way PostgREST can key a result row.

packages/stack/__tests__/supabase-v3-pgrest-live.test.ts renamed to packages/stack/integration/supabase/wire.integration.test.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,21 @@
2828
* `permission denied for schema eql_v3_internal` (see `supabase-v3-grants-pg`).
2929
*/
3030

31-
import 'dotenv/config'
31+
import { databaseUrl } from '@cipherstash/test-kit'
3232
import postgres from 'postgres'
33-
import { afterAll, beforeAll, expect, it } from 'vitest'
33+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
3434
import type { EncryptionClient } from '@/encryption'
3535
import { encryptedTable, types } from '@/eql/v3'
3636
import { EncryptedQueryBuilderV3Impl } from '@/supabase/query-builder-v3'
37-
import { SUPABASE_PERMISSIONS_SQL_V3 } from '../../cli/src/installer/grants'
38-
import { installEqlV3IfNeeded } from './helpers/eql-v3'
3937
import {
40-
describeLiveSupabasePgrest,
41-
LIVE_SUPABASE_PGREST_ENABLED,
42-
} from './helpers/live-gate'
43-
import { makePostgrestClient, reloadSchemaCache } from './helpers/pgrest'
44-
import { narrowedQueryTerm, storageEnvelope } from './helpers/v3-envelope'
38+
narrowedQueryTerm,
39+
storageEnvelope,
40+
} from '../../__tests__/helpers/v3-envelope'
41+
import { makePostgrestClient, reloadSchemaCache } from '../helpers/pgrest'
4542

4643
const TABLE = 'protect_ci_v3_pgrest'
4744

48-
const sql = LIVE_SUPABASE_PGREST_ENABLED
49-
? postgres(process.env.DATABASE_URL as string, { prepare: false })
50-
: (undefined as unknown as postgres.Sql)
45+
const sql = postgres(databaseUrl(), { prepare: false })
5146

5247
// A DECLARED table: `createdAt → created_at` is the rename the aliasing
5348
// `prop:db_name::jsonb` select exists for, and a synthesized table (property ==
@@ -211,12 +206,9 @@ function from(): any {
211206
const ADA_CREATED = new Date('2026-01-02T03:04:05.000Z')
212207

213208
beforeAll(async () => {
214-
if (!LIVE_SUPABASE_PGREST_ENABLED) return
215-
await installEqlV3IfNeeded(sql)
216-
217-
// The shipped Supabase grants — the thing under test, not a hand-rolled
218-
// approximation. Re-applied after install because the bundle DROPs eql_v3.
219-
await sql.unsafe(SUPABASE_PERMISSIONS_SQL_V3)
209+
// EQL v3 and the Supabase grants are installed once per run by `globalSetup`,
210+
// which shells out to the real `stash eql install --eql-version 3 --supabase`.
211+
// Re-applying them here would only test a hand-rolled approximation.
220212
await sql.unsafe(`DROP TABLE IF EXISTS ${TABLE}`)
221213
await sql.unsafe(`
222214
CREATE TABLE ${TABLE} (
@@ -245,12 +237,11 @@ beforeAll(async () => {
245237
}, 180_000)
246238

247239
afterAll(async () => {
248-
if (!LIVE_SUPABASE_PGREST_ENABLED) return
249240
await sql.unsafe(`DROP TABLE IF EXISTS ${TABLE}`)
250241
await sql.end()
251242
})
252243

253-
describeLiveSupabasePgrest('supabase v3 adapter over real PostgREST', () => {
244+
describe('supabase v3 adapter over real PostgREST (wire + grants)', () => {
254245
it('inserts raw envelopes that clear every domain CHECK, as anon', async () => {
255246
const { error, status } = await from().insert({
256247
row_key: 'ada',

packages/stack/integration/vitest.config.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,20 @@ import { sharedAlias } from '../../../vitest.shared'
1111
* jobs. That separation is what lets the integration suites throw on missing
1212
* configuration instead of skipping.
1313
*
14-
* `SUITE_GLOB` selects the adapter, so one config serves both without a second
15-
* near-identical file. CI passes it per job; locally it defaults to everything.
14+
* `CS_IT_SUITE` selects which suites run, so one config serves every adapter
15+
* without a second near-identical file. Comma-separated globs; each CI job scopes
16+
* itself to the adapter it provisioned a database for. Locally it defaults to
17+
* everything.
18+
*
19+
* Scoping matters: the Drizzle suites talk straight to Postgres and the Supabase
20+
* suites need PostgREST, so a job running both would have to provision both.
1621
*/
17-
const SUITE_GLOB =
22+
const SUITE_GLOBS = (
1823
process.env['CS_IT_SUITE'] ?? 'integration/**/*.integration.test.ts'
24+
)
25+
.split(',')
26+
.map((glob) => glob.trim())
27+
.filter(Boolean)
1928

2029
export default defineConfig({
2130
resolve: {
@@ -34,7 +43,7 @@ export default defineConfig({
3443
},
3544
test: {
3645
root: resolve(__dirname, '..'),
37-
include: [SUITE_GLOB],
46+
include: SUITE_GLOBS,
3847
globalSetup: [resolve(__dirname, 'global-setup.ts')],
3948
server: {
4049
deps: {

0 commit comments

Comments
 (0)