Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 13 additions & 7 deletions e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ const BIN = {
cli: resolve(REPO_ROOT, 'packages/cli/dist/bin/stash.js'),
wizard: resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js'),
protect: resolve(REPO_ROOT, 'packages/protect/dist/bin/stash.js'),
drizzleGen: resolve(REPO_ROOT, 'packages/drizzle/dist/bin/generate-eql-migration.js'),
drizzleGen: resolve(
REPO_ROOT,
'packages/drizzle/dist/bin/generate-eql-migration.js',
),
} as const

const UA: Record<PackageManager, string> = {
Expand All @@ -48,14 +51,12 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
{
label: 'base',
create: createBaseProvider,
firstStep: (r) =>
`Set up your database: ${r} stash db install`,
firstStep: (r) => `Set up your database: ${r} stash db install`,
},
{
label: 'drizzle',
create: createDrizzleProvider,
firstStep: (r) =>
`Set up your database: ${r} stash db install --drizzle`,
firstStep: (r) => `Set up your database: ${r} stash db install --drizzle`,
},
{
label: 'supabase',
Expand Down Expand Up @@ -201,13 +202,18 @@ describe.skipIf(!authConfigured)(
// in their --help output when executed under different package manager environments.
describe('binaries — help text uses detected runner', () => {
for (const pm of PMS) {
for (const [name, bin] of Object.entries(BIN) as Array<[keyof typeof BIN, string]>) {
for (const [name, bin] of Object.entries(BIN) as Array<
[keyof typeof BIN, string]
>) {
it(`${name} --help renders ${RUNNER[pm]} for pm=${pm}`, () => {
const result = spawnSync('node', [bin, '--help'], {
env: { ...process.env, npm_config_user_agent: UA[pm] },
encoding: 'utf8',
})
expect(result.status, `${name} --help (pm=${pm}) stderr: ${result.stderr}`).toBe(0)
expect(
result.status,
`${name} --help (pm=${pm}) stderr: ${result.stderr}`,
).toBe(0)
expect(result.stdout).toContain(RUNNER[pm])
if (RUNNER[pm] !== 'npx') {
expect(result.stdout).not.toMatch(/\bnpx\b/)
Expand Down
92 changes: 55 additions & 37 deletions e2e/tests/prisma-example-readme.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { spawnSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
Expand Down Expand Up @@ -29,9 +36,12 @@ function describeSpawnFailure(result: StepResult): string {
const lines = [`step \`${result.label}\` failed.`]
if (result.error) lines.push(` spawn error: ${result.error.message}`)
if (result.signal) lines.push(` killed by signal: ${result.signal}`)
if (typeof result.status === 'number') lines.push(` exit status: ${result.status}`)
if (result.stderr.trim()) lines.push(`--- stderr ---\n${result.stderr.trim()}`)
if (result.stdout.trim()) lines.push(`--- stdout ---\n${result.stdout.trim()}`)
if (typeof result.status === 'number')
lines.push(` exit status: ${result.status}`)
if (result.stderr.trim())
lines.push(`--- stderr ---\n${result.stderr.trim()}`)
if (result.stdout.trim())
lines.push(`--- stdout ---\n${result.stdout.trim()}`)
return lines.join('\n')
}

Expand Down Expand Up @@ -131,41 +141,49 @@ function timeoutFor(line: string): number {
const README_COMMANDS = parseRunItCommands(
readFileSync(resolve(EXAMPLE_DIR, 'README.md'), 'utf8'),
)
const EXECUTED_COMMANDS = README_COMMANDS.filter((line) => !SKIP_COMMANDS.has(line))
const EXECUTED_COMMANDS = README_COMMANDS.filter(
(line) => !SKIP_COMMANDS.has(line),
)

const outcomes = new Map<string, StepResult>()
let snapDir: string

describe.skipIf(!authConfigured)('examples/prisma README "Run it" walkthrough', () => {
beforeAll(async () => {
snapDir = await snapshotTransientOutputs()
await wipeTransientOutputs()

// Drive the walkthrough straight from the parsed README. `bash -c` keeps
// fidelity with what a user actually types — no argv tokenizer needed,
// future README evolutions (operators, quoting) Just Work.
for (const line of README_COMMANDS) {
if (SKIP_COMMANDS.has(line)) {
console.log(`[readme-walkthrough] skip: ${line}`)
continue
describe.skipIf(!authConfigured)(
'examples/prisma README "Run it" walkthrough',
() => {
beforeAll(async () => {
snapDir = await snapshotTransientOutputs()
await wipeTransientOutputs()

// Drive the walkthrough straight from the parsed README. `bash -c` keeps
// fidelity with what a user actually types — no argv tokenizer needed,
// future README evolutions (operators, quoting) Just Work.
for (const line of README_COMMANDS) {
if (SKIP_COMMANDS.has(line)) {
console.log(`[readme-walkthrough] skip: ${line}`)
continue
}
outcomes.set(line, runStep(line, timeoutFor(line)))
}
outcomes.set(line, runStep(line, timeoutFor(line)))
}
}, 600_000) // 10 min total budget for the cold path

afterAll(async () => {
// Teardown the bundled Postgres container regardless of outcome.
runStep('docker compose down -v', 60_000)
// Restore the transient outputs from snapshot so the working tree is clean.
await restoreTransientOutputs(snapDir)
// Remove the .env we copied in the walkthrough (not tracked anyway).
rmSync(join(EXAMPLE_DIR, '.env'), { force: true })
}, 120_000)

// Per-step exit-zero assertion, registered once per non-skipped README line.
it.each(EXECUTED_COMMANDS)('README "Run it" step exited 0: %s', (line) => {
const r = outcomes.get(line)
expect(r, `no outcome recorded for \`${line}\` — beforeAll did not run this step`).toBeDefined()
expect(r!.status, describeSpawnFailure(r!)).toBe(0)
})
})
}, 600_000) // 10 min total budget for the cold path

afterAll(async () => {
// Teardown the bundled Postgres container regardless of outcome.
runStep('docker compose down -v', 60_000)
// Restore the transient outputs from snapshot so the working tree is clean.
await restoreTransientOutputs(snapDir)
// Remove the .env we copied in the walkthrough (not tracked anyway).
rmSync(join(EXAMPLE_DIR, '.env'), { force: true })
}, 120_000)

// Per-step exit-zero assertion, registered once per non-skipped README line.
it.each(EXECUTED_COMMANDS)('README "Run it" step exited 0: %s', (line) => {
const r = outcomes.get(line)
expect(
r,
`no outcome recorded for \`${line}\` — beforeAll did not run this step`,
).toBeDefined()
expect(r!.status, describeSpawnFailure(r!)).toBe(0)
})
},
)
55 changes: 42 additions & 13 deletions e2e/tests/supply-chain.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ describe('supply chain — pnpm configuration', () => {
})

it('pnpm-workspace.yaml sets blockExoticSubdeps: true', () => {
const ws = readYaml('pnpm-workspace.yaml') as { blockExoticSubdeps?: boolean }
const ws = readYaml('pnpm-workspace.yaml') as {
blockExoticSubdeps?: boolean
}
expect(ws.blockExoticSubdeps).toBe(true)
})

it('onlyBuiltDependencies remains a small explicit allowlist (≤3 entries)', () => {
const allow = (readJson('package.json').pnpm?.onlyBuiltDependencies ?? []) as string[]
const allow = (readJson('package.json').pnpm?.onlyBuiltDependencies ??
[]) as string[]
expect(Array.isArray(allow)).toBe(true)
expect(allow.length).toBeLessThanOrEqual(3)
})
Expand All @@ -48,7 +51,9 @@ describe('supply chain — pnpm configuration', () => {
describe('supply chain — registry pinning (.npmrc)', () => {
it('pins @cipherstash scope and default registry to npmjs', () => {
const npmrc = read('.npmrc')
expect(npmrc).toMatch(/^@cipherstash:registry=https:\/\/registry\.npmjs\.org\/$/m)
expect(npmrc).toMatch(
/^@cipherstash:registry=https:\/\/registry\.npmjs\.org\/$/m,
)
expect(npmrc).toMatch(/^registry=https:\/\/registry\.npmjs\.org\/$/m)
})

Expand All @@ -62,7 +67,10 @@ describe('supply chain — registry pinning (.npmrc)', () => {
describe('supply chain — pnpm-lock.yaml integrity', () => {
it('every resolved package comes from registry.npmjs.org (no git/tarball deps)', () => {
const lock = readYaml('pnpm-lock.yaml') as {
packages?: Record<string, { resolution?: { tarball?: string; type?: string } }>
packages?: Record<
string,
{ resolution?: { tarball?: string; type?: string } }
>
}
const offenders: string[] = []
for (const [name, entry] of Object.entries(lock.packages ?? {})) {
Expand Down Expand Up @@ -90,7 +98,11 @@ describe('supply chain — CI hardening (.github/workflows/tests.yml)', () => {
string,
{
strategy?: { matrix?: Record<string, unknown> }
steps: Array<{ run?: string; uses?: string; with?: Record<string, unknown> }>
steps: Array<{
run?: string
uses?: string
with?: Record<string, unknown>
}>
}
>
}
Expand All @@ -105,7 +117,9 @@ describe('supply chain — CI hardening (.github/workflows/tests.yml)', () => {
(s) => typeof s.run === 'string' && PNPM_INSTALL.test(s.run),
)
for (const step of installSteps) {
expect(step.run, `${jobName} step "${step.run}"`).toMatch(/--frozen-lockfile/)
expect(step.run, `${jobName} step "${step.run}"`).toMatch(
/--frozen-lockfile/,
)
}
}
})
Expand All @@ -114,29 +128,40 @@ describe('supply chain — CI hardening (.github/workflows/tests.yml)', () => {
for (const [jobName, job] of Object.entries(workflow.jobs)) {
const usesPnpm = job.steps.some(
(s) =>
(typeof s.uses === 'string' && s.uses.startsWith('pnpm/action-setup')) ||
(typeof s.uses === 'string' &&
s.uses.startsWith('pnpm/action-setup')) ||
(typeof s.run === 'string' && /\bpnpm\b/.test(s.run)),
)
if (!usesPnpm) continue
const setup = job.steps.find(
(s) => typeof s.uses === 'string' && s.uses.startsWith('actions/setup-node'),
(s) =>
typeof s.uses === 'string' && s.uses.startsWith('actions/setup-node'),
)
expect(setup, `${jobName} uses pnpm but lacks actions/setup-node`).toBeTruthy()
expect(
setup,
`${jobName} uses pnpm but lacks actions/setup-node`,
).toBeTruthy()
const nv = String(setup?.with?.['node-version'])
if (nv === '22') continue
// Allow `${{ matrix.<key> }}` only when that matrix key resolves to
// an array of versions that includes 22 — so the matrix can broaden
// coverage without ever dropping the Node 22 hardening baseline.
const matrixRef = nv.match(/^\$\{\{\s*matrix\.([\w-]+)\s*\}\}$/)
expect(matrixRef, `${jobName} node version: expected '22' or matrix expression, got '${nv}'`).toBeTruthy()
expect(
matrixRef,
`${jobName} node version: expected '22' or matrix expression, got '${nv}'`,
).toBeTruthy()
const matrixKey = matrixRef![1]
const versions = job.strategy?.matrix?.[matrixKey]
expect(
Array.isArray(versions),
`${jobName} references matrix.${matrixKey} but no such array on strategy.matrix`,
).toBe(true)
const versionStrings = (versions as unknown[]).map((v) => String(v))
expect(versionStrings, `${jobName} matrix.${matrixKey} must include 22`).toContain('22')
expect(
versionStrings,
`${jobName} matrix.${matrixKey} must include 22`,
).toContain('22')
}
})
})
Expand All @@ -156,7 +181,9 @@ describe('supply chain — automated dependency updates (Dependabot)', () => {
})

it('github-actions ecosystem is also covered with a ≥ 3 day cooldown', () => {
const gha = db.updates.find((u) => u['package-ecosystem'] === 'github-actions')
const gha = db.updates.find(
(u) => u['package-ecosystem'] === 'github-actions',
)
expect(gha).toBeDefined()
expect(gha?.cooldown?.['default-days']).toBeGreaterThanOrEqual(3)
})
Expand All @@ -183,7 +210,9 @@ describe('supply chain — governance (CODEOWNERS)', () => {
const rule = rules.find((l) => l.includes(path))
expect(rule, `no CODEOWNERS rule covers ${path}`).toBeDefined()
const owners = rule!.split(/\s+/).slice(1)
expect(owners, `${path} CODEOWNERS owners`).toContain('@cipherstash/developers')
expect(owners, `${path} CODEOWNERS owners`).toContain(
'@cipherstash/developers',
)
}
})
})
9 changes: 5 additions & 4 deletions examples/basic/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'dotenv/config'
import readline from 'node:readline'
import { client, users } from './encrypt'
import { getAllContacts, createContact } from './src/queries/contacts'
import { createContact, getAllContacts } from './src/queries/contacts'

const rl = readline.createInterface({
input: process.stdin,
Expand Down Expand Up @@ -78,7 +78,7 @@ async function main() {
const newContact = {
name: 'John Doe',
email: 'john@example.com',
role: 'Developer' // This field will be encrypted using CipherStash
role: 'Developer', // This field will be encrypted using CipherStash
}

// Note: This would fail in this basic example since we don't have actual Supabase setup
Expand All @@ -89,9 +89,10 @@ async function main() {
console.log('Fetching encrypted contacts...')
// const contacts = await getAllContacts()
// console.log('Decrypted contacts:', contacts.data)

} catch (error) {
console.log('Supabase demo skipped (no actual Supabase connection in this basic example)')
console.log(
'Supabase demo skipped (no actual Supabase connection in this basic example)',
)
}

rl.close()
Expand Down
7 changes: 5 additions & 2 deletions examples/basic/src/encryption/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core'
import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle'
import { Encryption } from '@cipherstash/stack'
import {
encryptedType,
extractEncryptionSchema,
} from '@cipherstash/stack/drizzle'
import { integer, pgTable, timestamp } from 'drizzle-orm/pg-core'

export const usersTable = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
Expand Down
4 changes: 2 additions & 2 deletions examples/basic/src/lib/supabase/encrypted.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { encryptedSupabase } from '@cipherstash/stack/supabase'
import { encryptionClient, contactsTable } from '../../encryption/index'
import { contactsTable, encryptionClient } from '../../encryption/index'
import { createServerClient } from './server'

const supabase = await createServerClient()
export const eSupabase = encryptedSupabase({
encryptionClient,
supabaseClient: supabase,
})
})
2 changes: 1 addition & 1 deletion examples/basic/src/lib/supabase/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export async function createServerClient() {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

return createClient(supabaseUrl, supabaseKey)
}
}
Loading
Loading