Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6c19983
feat(e2e): start the Postgres v2 migration harness foundation
tobyhede Nov 11, 2026
6a4f85c
Merge branch 'reconcile/selftest' into feat/migration-harness-postgre…
tobyhede Nov 11, 2026
9d3e705
Merge branch 'reconcile/selftest' into feat/migration-harness-postgre…
tobyhede Nov 11, 2026
ea43a18
Merge branch 'reconcile/selftest' into feat/migration-harness-postgre…
tobyhede Nov 11, 2026
f739c25
feat(e2e): run Postgres v2 migrations through the isolated harness
tobyhede Nov 11, 2026
040d365
fix(e2e): project PostgreSQL snapshot domains as JSONB
tobyhede Nov 11, 2026
775f8d2
test(e2e): cover domain projection in PostgreSQL snapshots
tobyhede Nov 11, 2026
4dbebc7
Merge branch 'reconcile/selftest' into feat/migration-harness-postgre…
tobyhede Nov 11, 2026
0adf440
fix(e2e): classify PostgreSQL schema damage as migration failure
tobyhede Nov 11, 2026
b719568
fix(e2e): verify complete PostgreSQL migration storage
tobyhede Nov 11, 2026
da907ea
chore(e2e): merge the strace tracee attribution fix
tobyhede Nov 11, 2026
f337459
chore(e2e): merge the snapshot classification review fixes
tobyhede Nov 11, 2026
36d0604
chore(e2e): merge the npm 12 Stack resolution fix
tobyhede Nov 11, 2026
d462286
chore(e2e): merge the isolation Git ownership fix
tobyhede Nov 11, 2026
acf2883
chore(e2e): merge the held-descriptor observer fix
tobyhede Nov 11, 2026
4666aba
fix(e2e): run the PostgreSQL conversion through its own migrate entry…
tobyhede Nov 11, 2026
0909227
fix(e2e): validate the installed migration application
tobyhede Nov 11, 2026
95de9ee
chore(e2e): integrate audit fixes into PostgreSQL runs
tobyhede Nov 11, 2026
254b1da
chore(e2e): integrate supervisor frame compatibility
tobyhede Nov 11, 2026
5b7f28a
chore(e2e): integrate tracee lifetime repair into PostgreSQL runs
tobyhede Nov 12, 2026
8fa8a97
chore(e2e): integrate socket error classification into PostgreSQL runs
tobyhede Nov 12, 2026
1d42d00
chore(e2e): integrate friction schema into PostgreSQL runs
tobyhede Nov 12, 2026
a1b1576
chore(e2e): integrate explicit process tracing fix
tobyhede Nov 12, 2026
d631f62
fix(e2e): snapshot as the Run role and pin the EQL v2 bundle
tobyhede Nov 12, 2026
3cf2e82
fix(e2e): keep allowlisted error codes across the App Host RPC
tobyhede Nov 12, 2026
b69fec4
fix(e2e): check PostgreSQL conversion before the app starts
tobyhede Nov 12, 2026
d4c3822
chore(e2e): integrate traced actor identity repair
tobyhede Nov 12, 2026
e5b3e68
Merge remote-tracking branch 'origin/feat/migration-harness-postgres-…
tobyhede Nov 12, 2026
e77fac2
chore(e2e): integrate reviewed Oracle fixes into PostgreSQL Runs
tobyhede Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions e2e/migration-harness/__tests__/app-host-error-frame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath } from 'node:url'
import { expect, test } from 'vitest'

const appHost = fileURLToPath(new URL('../run/app-host.mjs', import.meta.url))

// The App Host is the RPC boundary: safePostgresCause classifies an application
// failure by its SQLSTATE, so the frame must carry an allowlisted code.
test('App Host error frames carry an allowlisted error code and drop any other', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'mh-app-host-frame-'))
try {
await mkdir(join(workspace, 'src'))
await writeFile(
join(workspace, 'src/stores.mjs'),
'export async function createStores() { return { init: async () => {}, close: async () => {} } }\n',
)
await writeFile(
join(workspace, 'src/app.mjs'),
`export async function createApp() {
return {
get: async (code) => { throw Object.assign(Error('application failure'), { code }) },
close: async () => {},
}
}
`,
)
const child = spawn(process.execPath, [appHost], {
cwd: workspace,
stdio: ['pipe', 'pipe', 'inherit'],
})
const lines = createInterface({ input: child.stdout })[
Symbol.asyncIterator
]()
const call = async (id: number, code: string) => {
child.stdin.write(
`${JSON.stringify({ id, method: 'get', args: [code] })}\n`,
)
const { value } = await lines.next()
return JSON.parse(String(value))
}
expect(await call(1, '42P01')).toEqual({
id: 1,
error: 'application failure',
code: '42P01',
})
expect(await call(2, 'ECONNRESET')).toMatchObject({ code: 'ECONNRESET' })
expect(await call(3, 'ada@example.com')).toEqual({
id: 3,
error: 'application failure',
})
child.stdin.end()
await new Promise((resolve) => child.on('close', resolve))
} finally {
await rm(workspace, { recursive: true, force: true })
}
})
136 changes: 136 additions & 0 deletions e2e/migration-harness/__tests__/installed-copy-checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'vitest'
import { migrationChecks } from '../run/migration-checks.mjs'
import { run } from '../run/run.mjs'

const manifest = { dependencies: { '@cipherstash/stack': '1.2.1' } }
const lock = {
packages: {
'': manifest,
'node_modules/@cipherstash/stack': { version: '1.2.1' },
'node_modules/@cipherstash/protect-ffi': { version: '0.31.0' },
},
}

for (const change of [
{
name: 'a Protect import',
path: 'src/app.mjs',
text: 'import "@cipherstash/protect"',
check: 'migration.protect-dependencies-absent',
},
{
name: 'a CTS endpoint shim',
path: 'src/app.mjs',
text: 'process.env.CS_CTS_HOST = "https://example.invalid"',
check: 'migration.endpoint-shim-absent',
},
{
name: 'a ZeroKMS endpoint shim',
path: 'src/app.mjs',
text: 'process.env.CS_ZEROKMS_HOST = "https://example.invalid"',
check: 'migration.endpoint-shim-absent',
},
{
name: 'a legacy manifest dependency',
path: 'package.json',
text: JSON.stringify({
dependencies: {
...manifest.dependencies,
'@cipherstash/protect': '10.2.1',
},
}),
check: 'migration.protect-dependencies-absent',
},
{
name: 'a legacy lockfile dependency',
path: 'package-lock.json',
text: JSON.stringify({
packages: {
...lock.packages,
'node_modules/@cipherstash/protect': { version: '10.2.1' },
},
}),
check: 'migration.protect-dependencies-absent',
},
{
name: 'a legacy FFI declaration',
path: 'package.json',
text: JSON.stringify({
dependencies: {
...manifest.dependencies,
'@cipherstash/protect-ffi': '0.18.1',
},
}),
check: 'migration.protect-ffi-version',
},
]) {
test(`Run rejects ${change.name} introduced in the installed App Host copy`, async () => {
const directory = await mkdtemp(join(tmpdir(), 'mh-installed-checks-'))
const workspace = join(directory, 'migrator')
const installedWorkspace = join(directory, 'app-host')
try {
await mkdir(join(workspace, 'src'), { recursive: true })
await writeFile(join(workspace, 'package.json'), JSON.stringify(manifest))
await writeFile(
join(workspace, 'package-lock.json'),
JSON.stringify(lock),
)
await writeFile(
join(workspace, 'src/app.mjs'),
'import "@cipherstash/stack/v3"',
)
await cp(workspace, installedWorkspace, { recursive: true })
const inspect = () =>
migrationChecks({
workspace,
installedWorkspace,
stackVersion: '1.2.1',
ffiRange: '0.31.0',
mutationSnapshot: { dynamo: { Item: { active__source: 'cipher' } } },
})
assert.ok(
(await inspect()).every((check) => check.passed),
'The unchanged installed app must satisfy the migration requirements',
)
const report = await run({
id: 'installed-copy',
manifest: { guideHash: 'guide' },
driver: {
provision: async () => {},
installUnmigrated: async () => {},
initializeLegacyData: async () => {},
snapshot: async () => {},
migrate: async () => ({ exitCode: 0 }),
friction: async () => [],
installAppHost: async () => {
// Model a lifecycle script changing only the deployed copy.
await writeFile(join(installedWorkspace, change.path), change.text)
},
behaviour: async () => [
{ id: 'no-rewrite.legacy-bytes', passed: true },
...(await inspect()),
],
},
})
assert.equal(
report.outcome,
'behaviour',
'A clean Migrator workspace must not hide installed-copy regressions',
)
assert.ok(
report.evidence.some(
(entry) =>
entry.checkId === change.check &&
Object(entry.payload).passed === false,
),
`The Run must name the failed ${change.check} requirement`,
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
}
58 changes: 58 additions & 0 deletions e2e/migration-harness/__tests__/isolation-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,40 @@ beforeEach(() => {
live.set(args[args.indexOf('--name') + 1], { actor, child })
return
}
if (actor === 'rpc-error-probe') {
// Answers every App Host request with an application error frame,
// including requests written before this mock turn ran.
const answer = (line: string) => {
const { id, args: callArgs } = JSON.parse(line)
const reply = {
id,
error: 'relation does not exist',
code: callArgs[0],
}
child.stdout.write(
`${JSON.stringify({
kind: 'stdout',
data: Buffer.from(`${JSON.stringify(reply)}\n`).toString(
'base64',
),
})}\n`,
)
}
for (const [line] of child.stdin.write.mock.calls) answer(line)
child.stdin.write.mockImplementation(answer)
child.stdin.end.mockImplementation(() => {
for (const value of [
{ kind: 'actor-exit', actor, stage: 'command', exitCode: 0 },
{ kind: 'stream-end' },
{ kind: 'exit', exitCode: 0 },
])
child.stdout.write(`${JSON.stringify(value)}\n`)
child.stdout.end()
child.stderr.end()
child.emit('close', 0)
})
return
}
const frame = (value: Record<string, unknown>) =>
child.stdout.write(`${JSON.stringify(value)}\n`)
if (actor === 'git-tool-probe') {
Expand Down Expand Up @@ -163,6 +197,30 @@ test('observer termination without an actor exit record is infrastructure even w

afterEach(() => vi.clearAllMocks())

test('an App Host error frame keeps an allowlisted PostgreSQL code across the RPC boundary', async () => {
const isolation = createIsolation()
await isolation.prove()
const host = await isolation.openAppHost({
actor: 'rpc-error-probe',
workspace: '/synthetic-workspace',
})
try {
const rejection = (code: string) =>
host.call('get', [code]).then(
() => null,
(error: Error & { code?: unknown }) => error,
)
const missing = await rejection('42P01')
expect(missing?.message).toBe('relation does not exist')
expect(missing?.code).toBe('42P01')
// Free-form codes are application data, not classification evidence.
const unexpected = await rejection('customer ada@example.com')
expect(unexpected).not.toHaveProperty('code')
} finally {
await host.close()
}
})

test('an App Host that already exited rejects later calls so the Run can report failure', async () => {
const isolation = createIsolation()
await isolation.prove()
Expand Down
56 changes: 56 additions & 0 deletions e2e/migration-harness/__tests__/postgres-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from 'vitest'
import {
postgresBehaviour,
postgresLegacy,
} from '../run/postgres-behaviour.mjs'

test('Postgres behaviour separates read-only checks from fresh writes', async () => {
const rows = new Map<string, Record<string, unknown>>(
postgresLegacy.map((row) => [row.pk, row]),
)
const calls: string[] = []
const result = await postgresBehaviour(async (method, args) => {
calls.push(method)
const value = args[0]
if (method === 'put') {
const row = Object.fromEntries(Object.entries(Object(value)))
rows.set(String(row.pk), row)
return null
}
if (method === 'get') return rows.get(String(value)) ?? null
const column = method === 'findEmail' ? 'email' : 'age'
return [...rows.values()]
.filter((row) => row[column] === value)
.sort((a, b) => String(a.pk).localeCompare(String(b.pk)))
})
expect(calls).not.toContain('put')
expect(result.checks.every((check) => check.passed)).toBe(true)
expect((await result.mutate()).every((check) => check.passed)).toBe(true)
expect(calls).toContain('put')
})

test('Postgres behaviour identifies a query mapping that returns only one equal-age row', async () => {
const result = await postgresBehaviour(async (method, args) => {
if (method === 'get')
return postgresLegacy.find((row) => row.pk === args[0])
if (method === 'findEmail')
return postgresLegacy.filter((row) => row.email === args[0])
return postgresLegacy.filter((row) => row.age === args[0]).slice(0, 1)
})
expect(result.checks).toContainEqual(
expect.objectContaining({
id: 'behaviour.postgres-age-lookup',
passed: false,
}),
)
})

test('Postgres failures retain safe diagnostic codes without provider or SQL text', async () => {
const result = await postgresBehaviour(async () => {
throw Object.assign(Error('secret-sentinel in SQL or provider response'), {
code: '42P01',
})
})
expect(result.checks[0].cause).toContain('42P01')
expect(JSON.stringify(result.checks)).not.toContain('secret-sentinel')
})
Loading
Loading