Skip to content

Commit 2354af4

Browse files
committed
test(stack): live Postgres introspection + unmodelled-domain partition
Adds describeLivePgOnly (DATABASE_URL only — introspection reads the schema, it does not encrypt) and asserts its LIVE_PG_ENABLED flag in the ungated live-coverage guard, so a missing DATABASE_URL fails CI loudly instead of silently skipping the suite on a green job.
1 parent 6769ca2 commit 2354af4

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,16 @@ export const LIVE_LOCK_CONTEXT_ENABLED = Boolean(
3737
export const describeLive = LIVE_CIPHERSTASH_ENABLED ? describe : describe.skip
3838

3939
export const describeLivePg = LIVE_EQL_V3_PG_ENABLED ? describe : describe.skip
40+
41+
/**
42+
* True when only a Postgres `DATABASE_URL` is configured (no CipherStash creds
43+
* needed — introspection reads the schema, it does not encrypt).
44+
*
45+
* Like the flags above, a false value turns its suites into `describe.skip`,
46+
* which in CI would be a silent whole-suite skip on a green job. That hole is
47+
* closed by `../live-coverage-guard.test.ts`, which asserts THIS flag in CI.
48+
* Any new gate flag added here must be asserted there too.
49+
*/
50+
export const LIVE_PG_ENABLED = Boolean(process.env.DATABASE_URL)
51+
52+
export const describeLivePgOnly = LIVE_PG_ENABLED ? describe : describe.skip

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
LIVE_CIPHERSTASH_ENABLED,
5757
LIVE_EQL_V3_PG_ENABLED,
5858
LIVE_LOCK_CONTEXT_ENABLED,
59+
LIVE_PG_ENABLED,
5960
} from './helpers/live-gate'
6061

6162
// GitHub Actions always sets CI=true; treat any truthy CI as "must run live".
@@ -110,6 +111,22 @@ describe('live-coverage guard', () => {
110111
},
111112
)
112113

114+
it.runIf(IN_CI)(
115+
'CI must have DATABASE_URL so the pg-only suites do not silently skip',
116+
() => {
117+
expect(
118+
LIVE_PG_ENABLED,
119+
'CI must run the DB-only suites — `LIVE_PG_ENABLED` is false. This ' +
120+
'needs only `DATABASE_URL` (no CS_* creds: introspection reads the ' +
121+
'schema, it does not encrypt), and `.github/workflows/tests.yml` ' +
122+
'writes it as a literal into packages/stack/.env alongside a Postgres ' +
123+
'service — so a false value here is a broken workflow, never a valid ' +
124+
'configuration. It makes every `describeLivePgOnly` suite (e.g. ' +
125+
'supabase-v3-introspect-pg) silently skip while CI stays green.',
126+
).toBe(true)
127+
},
128+
)
129+
113130
// Local dev with no creds: nothing to assert. Keep at least one always-run
114131
// assertion so the file is never reported as fully empty/pending.
115132
it('is always collected (guard file runs outside every live gate)', () => {
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import 'dotenv/config'
2+
import postgres from 'postgres'
3+
import { afterAll, beforeAll, expect, it } from 'vitest'
4+
import { factoryForDomain } from '@/eql/v3/domain-registry'
5+
import { introspect } from '@/supabase/introspect'
6+
import {
7+
assertModelledDomains,
8+
synthesizeTables,
9+
} from '@/supabase/schema-builder'
10+
import { installEqlV3IfNeeded } from './helpers/eql-v3'
11+
import { describeLivePgOnly, LIVE_PG_ENABLED } from './helpers/live-gate'
12+
13+
const databaseUrl = process.env.DATABASE_URL
14+
const sql = LIVE_PG_ENABLED
15+
? postgres(databaseUrl as string, { prepare: false })
16+
: (undefined as unknown as postgres.Sql)
17+
18+
const MODELLED = 'protect_ci_v3_introspect'
19+
const UNMODELLED = 'protect_ci_v3_unmodelled'
20+
21+
beforeAll(async () => {
22+
if (!LIVE_PG_ENABLED) return
23+
await installEqlV3IfNeeded(sql)
24+
await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`)
25+
await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`)
26+
await sql.unsafe(`
27+
CREATE TABLE ${MODELLED} (
28+
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
29+
email public.text_search NOT NULL,
30+
amount public.integer_ord NOT NULL,
31+
note TEXT,
32+
meta jsonb
33+
)
34+
`)
35+
// Columns typed with EQL domains that have NO types factory.
36+
await sql.unsafe(`
37+
CREATE TABLE ${UNMODELLED} (
38+
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
39+
score public.integer_ord_ope NOT NULL,
40+
doc public.json
41+
)
42+
`)
43+
}, 30000)
44+
45+
afterAll(async () => {
46+
if (!LIVE_PG_ENABLED) return
47+
await sql.unsafe(`DROP TABLE IF EXISTS ${MODELLED}`)
48+
await sql.unsafe(`DROP TABLE IF EXISTS ${UNMODELLED}`)
49+
await sql.end()
50+
}, 30000)
51+
52+
describeLivePgOnly('eql_v3 supabase introspection', () => {
53+
it('detects EQL v3 domains and classifies plaintext columns', async () => {
54+
const { tables } = await introspect(databaseUrl as string)
55+
const table = tables.find((t) => t.tableName === MODELLED)
56+
expect(table).toBeDefined()
57+
const domains = Object.fromEntries(
58+
table!.columns.map((c) => [c.columnName, c.domainName]),
59+
)
60+
expect(domains.email).toBe('text_search')
61+
expect(domains.amount).toBe('integer_ord')
62+
// Plaintext + plain jsonb → NULL (udt_name is jsonb for all of these).
63+
expect(domains.id).toBeNull()
64+
expect(domains.note).toBeNull()
65+
expect(domains.meta).toBeNull()
66+
}, 30000)
67+
68+
it('round-trips the domain → builder mapping via synthesizeTables', async () => {
69+
const { tables, eqlDomains } = await introspect(databaseUrl as string)
70+
// Sanity: the modelled domains are recognised as EQL domains (by COMMENT).
71+
expect(eqlDomains.has('text_search')).toBe(true)
72+
expect(eqlDomains.has('integer_ord')).toBe(true)
73+
74+
const { tables: synth, allColumns } = synthesizeTables(tables)
75+
const table = synth.get(MODELLED)
76+
expect(Object.keys(table!.columnBuilders).sort()).toEqual([
77+
'amount',
78+
'email',
79+
])
80+
expect(table!.columnBuilders.email.getEqlType()).toBe('public.text_search')
81+
expect(table!.columnBuilders.amount.getEqlType()).toBe('public.integer_ord')
82+
expect(allColumns.get(MODELLED)).toEqual([
83+
'id',
84+
'email',
85+
'amount',
86+
'note',
87+
'meta',
88+
])
89+
}, 30000)
90+
91+
it('detects unmodelled EQL domains (by COMMENT) and the guard rejects them', async () => {
92+
const { tables, eqlDomains } = await introspect(databaseUrl as string)
93+
94+
// The COMMENT predicate recognises these as EQL domains...
95+
expect(eqlDomains.has('integer_ord_ope')).toBe(true)
96+
expect(eqlDomains.has('json')).toBe(true)
97+
// ...and they have no types factory (genuinely unmodelled)...
98+
expect(factoryForDomain('integer_ord_ope')).toBeUndefined()
99+
expect(factoryForDomain('json')).toBeUndefined()
100+
101+
// Scope the guard to THIS test's tables. `introspect` returns every table in
102+
// `public`, and a developer's DATABASE_URL may already carry an unmodelled
103+
// EQL column of its own — the assertion would then pass for the wrong
104+
// reason, or fail naming a domain this test never created.
105+
const modelledOnly = tables.filter((t) => t.tableName === MODELLED)
106+
const scoped = tables.filter(
107+
(t) => t.tableName === MODELLED || t.tableName === UNMODELLED,
108+
)
109+
expect(modelledOnly).toHaveLength(1)
110+
expect(scoped).toHaveLength(2)
111+
112+
// The modelled table alone must NOT trip the guard.
113+
expect(() => assertModelledDomains(modelledOnly, eqlDomains)).not.toThrow()
114+
115+
// ...and the unmodelled one throws, naming the offending column/domain.
116+
expect(() => assertModelledDomains(scoped, eqlDomains)).toThrow(
117+
/integer_ord_ope|public\.json/,
118+
)
119+
}, 30000)
120+
})

0 commit comments

Comments
 (0)