diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81dc63f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Repository conformance + run: bunx @hasna/contracts@0.8.2 repo-conformance . + + - name: Vendored storage kit is current + run: bunx @hasna/contracts@0.8.2 vendor-kit --check . + + # The publish gate itself: typecheck, tests, build, packed-artifact scan. + # Running the same script prepack runs is what keeps the wiring honest. + - name: Release verification + run: bun run verify:release + + live-postgres: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: attachments + POSTGRES_PASSWORD: attachments + POSTGRES_DB: attachments_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U attachments -d attachments_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + # The command declared in hasna.contract.json (storage.pgTestGate). + - name: Live PostgreSQL gate + env: + HASNA_ATTACHMENTS_TEST_DATABASE_URL: postgresql://attachments:attachments@127.0.0.1:5432/attachments_test + run: | + HASNA_ATTACHMENTS_DATABASE_URL="${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}" \ + HASNA_ATTACHMENTS_STORAGE_MODE=cloud \ + ATTACHMENTS_REQUIRE_POSTGRES=1 \ + bun test src/db + + # A gate whose exit status is the same with and without a database is not + # a gate. Prove the difference on every run instead of trusting it. + - name: Gate must fail against a dead database + env: + HASNA_ATTACHMENTS_TEST_DATABASE_URL: postgres://nobody:nope@127.0.0.1:1/doesnotexist + run: | + set +e + HASNA_ATTACHMENTS_DATABASE_URL="$HASNA_ATTACHMENTS_TEST_DATABASE_URL" \ + HASNA_ATTACHMENTS_STORAGE_MODE=cloud \ + ATTACHMENTS_REQUIRE_POSTGRES=1 \ + bun test src/db + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "::error::live-PostgreSQL gate reported a pass against an unreachable database" >&2 + exit 1 + fi + echo "gate failed as expected (exit $status)" diff --git a/bun.lock b/bun.lock index 0cfaca3..900c9ed 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@aws-sdk/client-s3": "^3.1007.0", "@aws-sdk/lib-storage": "3.1007.0", "@aws-sdk/s3-request-presigner": "^3.1007.0", - "@hasna/contracts": "^0.5.2", + "@hasna/contracts": "^0.8.2", "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.27.1", "@types/mime-types": "^3.0.1", @@ -67,7 +67,7 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], - "@hasna/contracts": ["@hasna/contracts@0.5.2", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/index.js" } }, "sha512-R7NBm91oMOWQVnfIyh7QTEPuWbVycGaObzKFJd1je5RNjXsLuPASqCfr9heJ7nuugn9xxFBCCRQCxHg9/VeJrg=="], + "@hasna/contracts": ["@hasna/contracts@0.8.2", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-oi+Q1QyxARpTRmE2za1rCzETi7DqB0EeimI3hIh2iMKV2Sh0D2Mi6KkDqNLnYsRZKjxEH5VapFoprb1Vi+R9+Q=="], "@hasna/events": ["@hasna/events@0.1.13", "", { "dependencies": { "commander": "13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-DzmEiDrBoibxzCBeZPEmdpWsNh+5SXTSnilqCeHxi0Do3a9mQJ43ZSxq/Ipzw/xNa2rShAx48KzduCZrGDHi9g=="], diff --git a/hasna.contract.json b/hasna.contract.json index 4c4e701..42c8241 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -3,29 +3,66 @@ "name": "attachments", "class": "cli-with-store", "contractVersion": "v1", - "kitVersion": "0.4.1", + "kitVersion": "0.8.2", "description": "Open-source attachment transfer with local or private S3 storage, app-hosted share links, CLI, MCP, REST API (attachments-serve), and a generated SDK. Cloud service is PURE REMOTE (Amendment A1): reads/writes RDS Postgres and S3 directly.", "bins": ["attachments", "attachments-mcp", "attachments-serve"], + "hosting": ["user-hosted"], + "deploymentModes": ["local", "self_hosted"], "storage": { "mode": "cloud", + "engines": ["sqlite", "postgres"], "envPrefix": "HASNA_ATTACHMENTS_", "aliasEnvPrefix": "ATTACHMENTS_", - "databaseUrlSecretRef": "hasna/oss/attachments/database-url", - "sqlitePath": "~/.hasna/attachments/db.sqlite" + "pgTestGate": { + "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", + "command": "HASNA_ATTACHMENTS_DATABASE_URL=\"${HASNA_ATTACHMENTS_TEST_DATABASE_URL:?point it at a throwaway Postgres; the live-PG gate must not pass without one}\" HASNA_ATTACHMENTS_STORAGE_MODE=cloud ATTACHMENTS_REQUIRE_POSTGRES=1 bun test src/db" + } }, - "metadata": { - "surfaces": { - "cli": "attachments", - "mcp": "attachments-mcp", - "serve": "attachments-serve", - "sdk": "@hasna/attachments-sdk" + "serviceSurfaces": [ + { + "name": "attachments-api", + "kind": "api", + "status": "supported", + "bin": "attachments-serve", + "authMode": "api-key", + "deploymentModes": ["self_hosted"], + "health": { "method": "GET", "path": "/health", "public": true }, + "readiness": { "method": "GET", "path": "/ready", "public": true }, + "version": { "method": "GET", "path": "/version", "public": true }, + "apiBasePath": "/v1", + "openApiPath": "/openapi.json" + }, + { + "name": "attachments-sdk", + "kind": "sdk", + "status": "supported", + "authMode": "none", + "deploymentModes": ["local", "self_hosted"], + "exportSubpath": ".", + "generatedFrom": "/openapi.json" }, - "serve": { - "port": 8080, - "probes": ["/health", "/ready", "/version"], - "openapi": "/openapi.json", - "apiVersion": "v1", - "auth": "api-key (@hasna/contracts)" + { + "name": "attachments-mcp", + "kind": "mcp", + "status": "supported", + "mcpBin": "attachments-mcp", + "authMode": "local-only", + "deploymentModes": ["local", "self_hosted"] + }, + { + "name": "attachments-cli", + "kind": "cli", + "status": "supported", + "bin": "attachments", + "authMode": "local-only", + "deploymentModes": ["local", "self_hosted"] + } + ], + "metadata": { + "release": { + "artifactScan": { + "script": "scan:artifact" + } } } } diff --git a/package.json b/package.json index fefe561..a230ab9 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "typecheck": "bunx tsc --noEmit", "test": "bash scripts/test.sh", "test:coverage": "bash scripts/test.sh --coverage", - "verify:release": "bun run typecheck && bun run test && bun run build", + "verify:release": "bun run typecheck && bun run test && bun run build && bun run scan:artifact", + "prepack": "bun run verify:release", "prepublishOnly": "bun run verify:release", + "scan:artifact": "bun scripts/scan-artifact.ts", "dev": "bun run src/cli/index.ts", "dashboard": "cd dashboard && bun run dev", "dashboard:build": "cd dashboard && bun run build", @@ -63,7 +65,7 @@ "@aws-sdk/client-s3": "^3.1007.0", "@aws-sdk/lib-storage": "3.1007.0", "@aws-sdk/s3-request-presigner": "^3.1007.0", - "@hasna/contracts": "^0.5.2", + "@hasna/contracts": "^0.8.2", "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.27.1", "@types/mime-types": "^3.0.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3158760..4c7f226 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ minimumReleaseAgeExclude: - '@hasna/contracts@0.4.1' - '@hasna/contracts@0.5.2' + - '@hasna/contracts@0.8.2' diff --git a/scripts/scan-artifact.test.ts b/scripts/scan-artifact.test.ts new file mode 100644 index 0000000..13d713d --- /dev/null +++ b/scripts/scan-artifact.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { CONTRACTS_KIT_VERSION, scannerCommand, scanPackedArtifact } from "./scan-artifact"; + +const repoRoot = join(import.meta.dir, ".."); + +function readText(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), "utf8"); +} + +function readJson(relativePath: string): Record { + return JSON.parse(readText(relativePath)); +} + +/** Strip comments so a doc line naming an env API cannot mask a real read of it. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, ""); +} + +/** Strip a leading range operator so "^0.8.2" and "0.8.2" compare equal. */ +function rangeBaseVersion(range: string): string { + return range.replace(/^[\^~=v]+/, ""); +} + +/** + * Scripts reachable from `entry` through the pre/post lifecycle and `bun run` / + * `npm run` references. Mirrors the graph `@hasna/contracts repo-conformance` + * walks for its published_artifact_gate check, so the wiring is proven on every + * `bun run test` and not only when a human remembers to type the conformance + * CLI. + */ +function scriptsReachedBy(scripts: Record, entry: string): Set { + const reached = new Set(); + const queue: string[] = [entry]; + const enqueue = (name: string | undefined) => { + if (name && name in scripts) queue.push(name); + }; + while (queue.length > 0) { + const name = queue.shift() as string; + if (reached.has(name)) continue; + reached.add(name); + enqueue(`pre${name}`); + enqueue(`post${name}`); + const body = scripts[name]; + if (!body) continue; + for (const match of body.matchAll( + /\b(?:bun|bunx|npm|pnpm|yarn)\s+(?:(?:--\S+|-\w)\s+)*(?:run\s+)?([a-zA-Z0-9_][\w:.-]*)/g, + )) { + enqueue(match[1]); + } + } + return reached; +} + +describe("scan:artifact release gate", () => { + it("resolves the pinned scanner from source alone — the module reads no environment", () => { + // Setting one env name and asserting the argv is unchanged only proves the + // names we happened to think of; a bypass added under any other name stays + // green. Assert the invariant the module header claims instead: there is no + // environment input path at all, so there is nothing to override at publish + // time. + const source = stripComments(readText("scripts/scan-artifact.ts")); + expect(source).not.toMatch(/process\.env/); + expect(source).not.toMatch(/Bun\.env/); + expect(source).not.toMatch(/import\.meta\.env/); + expect(source).not.toMatch(/from\s+["']node:process["']/); + + expect(scannerCommand("/tmp/pkg.tgz")).toEqual([ + "bunx", + `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, + "artifact-scan", + "/tmp/pkg.tgz", + ]); + }); + + it("keeps prepack and prepublishOnly wired to the declared packed-artifact scan", () => { + // The deliverable here is the wiring, not the script. Drop `prepack`, or + // drop `scan:artifact` out of `verify:release`, and the scanner still runs + // clean in isolation while `bun publish` ships an unscanned artifact. + const scripts = readJson("package.json").scripts as Record; + const declared = readJson("hasna.contract.json").metadata?.release?.artifactScan?.script; + + expect(declared).toBe("scan:artifact"); + expect(scripts[declared]).toBe("bun scripts/scan-artifact.ts"); + expect(scripts["verify:release"]).toContain("bun run scan:artifact"); + + // npm/bun run `prepack` for `pm pack` and `prepublishOnly` for `publish`; + // a gate reachable from only one of them still has a publish-time hole. + for (const entry of ["prepack", "prepublishOnly"]) { + expect(scripts[entry]).toBeString(); + expect([...scriptsReachedBy(scripts, entry)]).toContain(declared); + } + }); + + it("enforces the conformance and release gates in CI, not only on a reviewer's laptop", () => { + // `contracts repo-conformance` is what checks published_artifact_gate. With + // no workflow it runs when someone types it, which is not a gate. + const workflow = readText(".github/workflows/ci.yml"); + expect(workflow).toContain(`bunx @hasna/contracts@${CONTRACTS_KIT_VERSION} repo-conformance .`); + expect(workflow).toContain(`bunx @hasna/contracts@${CONTRACTS_KIT_VERSION} vendor-kit --check .`); + expect(workflow).toContain("bun run verify:release"); + // The live-PG gate declared in the contract has to actually execute. + expect(workflow).toContain("HASNA_ATTACHMENTS_TEST_DATABASE_URL"); + expect(workflow).toContain("ATTACHMENTS_REQUIRE_POSTGRES"); + }); + + it("keeps the kit version in lockstep with the contract, vendored kit and dependency", () => { + expect(readJson("hasna.contract.json").kitVersion).toBe(CONTRACTS_KIT_VERSION); + expect(readJson("src/generated/storage-kit/.storage-kit-manifest.json").kitVersion).toBe( + CONTRACTS_KIT_VERSION, + ); + expect(rangeBaseVersion(readJson("package.json").dependencies["@hasna/contracts"])).toBe( + CONTRACTS_KIT_VERSION, + ); + // The pinned version must be quarantine-excluded or a fresh install stalls. + expect(readText("pnpm-workspace.yaml")).toContain(`'@hasna/contracts@${CONTRACTS_KIT_VERSION}'`); + }); + + it("packs the artifact and passes the scan with the pinned kit", () => { + // Proves the pin actually resolves on the registry: an unpublished version + // makes bunx exit 1 here, exactly as it would in prepack. + const { command, output } = scanPackedArtifact(); + expect(command[1]).toBe(`@hasna/contracts@${CONTRACTS_KIT_VERSION}`); + expect(output).toContain("pass artifact-scan"); + expect(output).toContain("packed_artifact"); + }, 300_000); +}); diff --git a/scripts/scan-artifact.ts b/scripts/scan-artifact.ts new file mode 100644 index 0000000..00b6968 --- /dev/null +++ b/scripts/scan-artifact.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env bun +/** + * Scan the PACKED release artifact for bulk asset inventories. + * + * This is the `scan:artifact` release gate declared in hasna.contract.json + * (metadata.release.artifactScan) and wired into prepack/prepublishOnly. + * Run: bun run scan:artifact + * + * The scanner version is pinned here and nowhere else. There is deliberately + * no environment override: a gate whose command can be replaced at publish + * time is the exact bypass the gate exists to close. scan-artifact.test.ts + * asserts the pin stays in lockstep with hasna.contract.json, the vendored + * storage kit and the @hasna/contracts dependency range. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +export const CONTRACTS_KIT_VERSION = "0.8.2"; + +function run(command: string[], cwd: string): string { + const result = Bun.spawnSync(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new TextDecoder().decode(result.stdout).trim(); + if (result.exitCode !== 0) { + const stderr = new TextDecoder().decode(result.stderr).trim(); + throw new Error(`${command.join(" ")} exited ${result.exitCode}\n${stdout}\n${stderr}`); + } + return stdout; +} + +export function scannerCommand(archive: string): string[] { + return ["bunx", `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, "artifact-scan", archive]; +} + +/** Pack the tarball npm would publish, then scan that tarball — never src/. */ +export function scanPackedArtifact(): { command: string[]; output: string } { + const repoRoot = join(import.meta.dir, ".."); + const workspace = mkdtempSync(join(tmpdir(), "attachments-artifact-scan-")); + + try { + const packed = run(["bun", "pm", "pack", "--destination", workspace, "--ignore-scripts", "--quiet"], repoRoot); + const archive = isAbsolute(packed) ? packed : join(workspace, packed); + const command = scannerCommand(archive); + return { command, output: run(command, repoRoot) }; + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + // Echo the resolved command so a scan that ran nothing is visible in publish logs. + const { command, output } = scanPackedArtifact(); + console.log(`$ ${command.join(" ")}`); + console.log(output); +} diff --git a/scripts/test.sh b/scripts/test.sh index 41da997..d457f1c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -37,7 +37,7 @@ if [[ "$1" == "--coverage" ]]; then COVERAGE_FLAG="--coverage" fi -mapfile -t TEST_FILES < <(find src sdk -type f -name "*.test.ts" | sort) +mapfile -t TEST_FILES < <(find src sdk scripts -type f -name "*.test.ts" | sort) for file in "${TEST_FILES[@]}"; do if bun test $COVERAGE_FLAG "$file" 2>&1; then diff --git a/src/db/migrations.pg.test.ts b/src/db/migrations.pg.test.ts new file mode 100644 index 0000000..c20cb27 --- /dev/null +++ b/src/db/migrations.pg.test.ts @@ -0,0 +1,139 @@ +/** + * Live-PostgreSQL coverage for ATTACHMENTS_MIGRATIONS — the gate declared in + * hasna.contract.json (storage.pgTestGate). + * + * `attachments-serve` runs these migrations on boot (src/serve/index.ts) and + * gates /ready on them (src/serve/app.ts), and the api_keys half of them ships + * from @hasna/contracts, so a dependency bump silently changes this schema. + * Nothing here is stubbed: a run without a reachable database fails. + */ + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { checkHealth, checkReady } from "../generated/storage-kit/health.js"; +import { MigrationLedger, defineMigration } from "../generated/storage-kit/migrations.js"; +import { ATTACHMENTS_MIGRATIONS } from "./migrations.js"; +import { + LIVE_PG_ENABLED, + LIVE_PG_URL_ENV, + REQUIRE_PG_ENV, + createLiveSchema, + resolveLivePgGate, + withSearchPath, + type LiveSchema, +} from "./pg-live.test-harness.test.js"; + +/** A closed port: nothing listens on 127.0.0.1:1. */ +const UNREACHABLE_DATABASE_URL = "postgres://nobody:nope@127.0.0.1:1/doesnotexist"; + +const repoRoot = join(import.meta.dir, "..", ".."); + +describe("live-PostgreSQL gate wiring", () => { + it("is the command hasna.contract.json declares", () => { + const gate = JSON.parse(readFileSync(join(repoRoot, "hasna.contract.json"), "utf8")).storage + ?.pgTestGate; + + expect(gate?.envVar).toBe(LIVE_PG_URL_ENV); + // Point the gate at the tests that connect. `src/serve` runs entirely on + // InMemoryAttachmentsStore, so including it inflates the pass count without + // touching Postgres. + expect(gate?.command).toContain("bun test src/db"); + expect(gate?.command).not.toContain("src/serve"); + // `${VAR:?...}` makes the shell fail when the operator forgot the database, + // and the flag makes the tests themselves refuse to skip. + expect(gate?.command).toContain(`\${${LIVE_PG_URL_ENV}:?`); + expect(gate?.command).toContain(`${REQUIRE_PG_ENV}=1`); + }); + + it("fails, rather than skipping, when engaged without a database", () => { + expect(() => resolveLivePgGate({ [REQUIRE_PG_ENV]: "1" })).toThrow( + /refuses to report a pass without a database/, + ); + expect(resolveLivePgGate({})).toEqual({ url: null, required: false }); + expect(resolveLivePgGate({ [LIVE_PG_URL_ENV]: " postgres://x/y ", [REQUIRE_PG_ENV]: "1" })).toEqual({ + url: "postgres://x/y", + required: true, + }); + }); + + it("fails against an unreachable database", async () => { + // The whole point: exit status must differ with and without a live server. + await expect(createLiveSchema("unreachable", UNREACHABLE_DATABASE_URL)).rejects.toThrow(); + }, 30_000); + + it("scopes each run to its own schema through the connection string", () => { + const scoped = withSearchPath("postgres://u:p@host:5432/db?sslmode=require", "att_gate_x"); + expect(new URL(scoped).searchParams.get("options")).toBe("-c search_path=att_gate_x"); + expect(new URL(scoped).searchParams.get("sslmode")).toBe("require"); + }); +}); + +describe.skipIf(!LIVE_PG_ENABLED)("ATTACHMENTS_MIGRATIONS against live PostgreSQL", () => { + let live: LiveSchema; + + beforeAll(async () => { + live = await createLiveSchema("migrations"); + }); + + afterAll(async () => { + await live?.drop(); + }); + + it("reaches the database", async () => { + const health = await checkHealth(live.client); + expect(health.error).toBeUndefined(); + expect(health.ok).toBe(true); + }); + + it("applies every declared migration, api_keys included", async () => { + const result = await new MigrationLedger(live.client, ATTACHMENTS_MIGRATIONS).migrate(); + + expect(result.plan.map((item) => item.state)).toEqual(ATTACHMENTS_MIGRATIONS.map(() => "pending")); + expect(result.applied.map((row) => row.id)).toEqual( + ATTACHMENTS_MIGRATIONS.map((migration) => migration.id).sort(), + ); + expect(result.applied.map((row) => row.id)).toContain("hasna_auth_0003_api_keys_tenant"); + }, 60_000); + + it("creates the api_keys.tid column the @hasna/contracts bump introduces", async () => { + // hasna_auth_0003 arrived with the 0.5.2 -> 0.8.2 bump in this PR and flows + // into ATTACHMENTS_MIGRATIONS; /ready reports not_ready until it applies. + const column = await live.client.get<{ data_type: string }>( + `SELECT data_type + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'api_keys' AND column_name = 'tid'`, + [live.schema], + ); + expect(column?.data_type).toBe("text"); + + const index = await live.client.get<{ indexname: string }>( + `SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND indexname = 'api_keys_tid_idx'`, + [live.schema], + ); + expect(index?.indexname).toBe("api_keys_tid_idx"); + }); + + it("reports ready with nothing pending and is idempotent on re-run", async () => { + const ready = await checkReady(live.client, ATTACHMENTS_MIGRATIONS); + expect(ready.error).toBeUndefined(); + expect(ready.pendingMigrations).toEqual([]); + expect(ready.ok).toBe(true); + + const rerun = await new MigrationLedger(live.client, ATTACHMENTS_MIGRATIONS).migrate(); + expect(rerun.plan.map((item) => item.state)).toEqual( + ATTACHMENTS_MIGRATIONS.map(() => "already_applied"), + ); + }, 60_000); + + it("refuses a migration whose SQL changed after it was applied", async () => { + // The ledger's checksum guard is what makes a contracts bump safe; it is + // only real if the checksums were persisted in Postgres. + const drifted = ATTACHMENTS_MIGRATIONS.map((migration, index) => + index === 0 ? defineMigration(migration.id, `${migration.sql} -- drift`) : migration, + ); + await expect(new MigrationLedger(live.client, drifted).migrate()).rejects.toThrow( + /checksum mismatch/, + ); + }); +}); diff --git a/src/db/pg-live.test-harness.test.ts b/src/db/pg-live.test-harness.test.ts new file mode 100644 index 0000000..a9858dc --- /dev/null +++ b/src/db/pg-live.test-harness.test.ts @@ -0,0 +1,148 @@ +/** + * Harness for the live-PostgreSQL gate declared in hasna.contract.json + * (storage.pgTestGate). + * + * These are the only tests in the repo that open a real Postgres connection. + * Everything under src/serve runs against `InMemoryAttachmentsStore`, so it + * proves nothing about migrations.ts or pg-store.ts — the two modules that are + * the entire reason `postgres` is a declared storage engine. + * + * Isolation: each run gets its own schema, scoped through the connection + * string's libpq `options=-c search_path=…`, so HASNA_ATTACHMENTS_TEST_DATABASE_URL + * may point at a shared throwaway database without runs colliding, and teardown + * drops only what the run created. + * + * The gate must not be able to pass without a database: + * - URL set -> connect for real; an + * unreachable database fails the suite. + * - URL unset, ATTACHMENTS_REQUIRE_POSTGRES=1 -> throw. The declared gate + * command exports that flag, so a vacuous green run is impossible. + * - URL unset, flag unset -> skip loudly, so `bun run + * test` stays green on a machine with no Postgres. + * + * Named `*.test-harness.test.ts` to match the existing convention in this repo + * (the runner tolerates a file with no tests). + */ + +import { randomBytes } from "node:crypto"; +import { createPgPool, createCloudPoolFromEnv } from "../generated/storage-kit/pool.js"; +import { createQueryClient, type PoolQueryClient } from "../generated/storage-kit/query.js"; + +const APP_SLUG = "attachments"; + +/** Env var an operator points at a throwaway Postgres to run the gate. */ +export const LIVE_PG_URL_ENV = "HASNA_ATTACHMENTS_TEST_DATABASE_URL"; +/** Set to `1` by the declared gate command: a missing database is then a failure, not a skip. */ +export const REQUIRE_PG_ENV = "ATTACHMENTS_REQUIRE_POSTGRES"; + +export interface LivePgGate { + /** Connection string for the throwaway database, or null when not supplied. */ + url: string | null; + /** Whether the caller engaged the gate and therefore forbids skipping. */ + required: boolean; +} + +/** + * Resolve the gate from an environment. Throws when the gate is engaged but no + * database was supplied — the failure mode that stops a gate reporting green + * against nothing. + */ +export function resolveLivePgGate(env: Record): LivePgGate { + const url = env[LIVE_PG_URL_ENV]?.trim(); + const required = env[REQUIRE_PG_ENV]?.trim() === "1"; + if (url) return { url, required }; + if (required) { + throw new Error( + `${REQUIRE_PG_ENV}=1 but ${LIVE_PG_URL_ENV} is unset: the live-PostgreSQL gate declared in ` + + `hasna.contract.json (storage.pgTestGate) refuses to report a pass without a database.`, + ); + } + return { url: null, required }; +} + +function announceSkip(): void { + console.warn( + `[live-pg] SKIPPED — ${LIVE_PG_URL_ENV} is unset, so src/db/migrations.ts and ` + + `src/db/pg-store.ts are NOT covered by this run. Point it at a throwaway Postgres ` + + `to run the gate declared in hasna.contract.json (storage.pgTestGate).`, + ); +} + +export const LIVE_PG_GATE: LivePgGate = resolveLivePgGate(process.env); +if (LIVE_PG_GATE.url === null) announceSkip(); + +/** True when this process has a database and the live suites should run. */ +export const LIVE_PG_ENABLED = LIVE_PG_GATE.url !== null; + +/** Scope every connection built from this URL to one schema, libpq style. */ +export function withSearchPath(connectionString: string, schema: string): string { + const url = new URL(connectionString); + url.searchParams.set("options", `-c search_path=${schema}`); + return url.toString(); +} + +export interface LiveSchema { + /** Name of the throwaway schema every table in this run lands in. */ + readonly schema: string; + /** Kit query client bound to that schema, built the way the service builds it. */ + readonly client: PoolQueryClient; + /** Close the pools and drop the schema. */ + drop(): Promise; +} + +/** + * Create an isolated schema and a kit client scoped to it. + * + * The scoped client is built through `createCloudPoolFromEnv`, the same + * entrypoint `attachments-serve` uses, so mode resolution, TLS handling and the + * pool wiring are all exercised rather than bypassed. + */ +export async function createLiveSchema(label: string, baseUrl?: string): Promise { + const connectionString = baseUrl ?? LIVE_PG_GATE.url; + if (!connectionString) { + throw new Error(`createLiveSchema needs ${LIVE_PG_URL_ENV} or an explicit connection string.`); + } + const schema = `att_gate_${label}_${randomBytes(6).toString("hex")}`; + + const admin = createQueryClient( + createPgPool({ + connectionString, + max: 2, + connectionTimeoutMillis: 10_000, + applicationName: "attachments-pg-gate-admin", + }), + ); + + let client: PoolQueryClient | null = null; + try { + await admin.execute(`CREATE SCHEMA "${schema}"`); + client = createCloudPoolFromEnv(APP_SLUG, { + env: { + ...process.env, + HASNA_ATTACHMENTS_STORAGE_MODE: "cloud", + HASNA_ATTACHMENTS_DATABASE_URL: withSearchPath(connectionString, schema), + }, + max: 4, + connectionTimeoutMillis: 10_000, + applicationName: "attachments-pg-gate", + }).client; + } catch (error) { + if (client) await client.close().catch(() => {}); + await admin.close().catch(() => {}); + throw error; + } + + const scoped = client; + return { + schema, + client: scoped, + async drop(): Promise { + await scoped.close().catch(() => {}); + try { + await admin.execute(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + } finally { + await admin.close().catch(() => {}); + } + }, + }; +} diff --git a/src/db/pg-store.pg.test.ts b/src/db/pg-store.pg.test.ts new file mode 100644 index 0000000..110b25e --- /dev/null +++ b/src/db/pg-store.pg.test.ts @@ -0,0 +1,158 @@ +/** + * Live-PostgreSQL coverage for PgAttachmentsStore — the store `attachments-serve` + * actually runs on. Part of the gate declared in hasna.contract.json + * (storage.pgTestGate). + * + * The src/serve suites swap this class for `InMemoryAttachmentsStore`, so the + * SQL below — placeholder numbering, BIGINT round-tripping, the share-link + * consume/release race guards — has no other coverage anywhere in the repo. + */ + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { MigrationLedger } from "../generated/storage-kit/migrations.js"; +import type { Attachment } from "../core/db.js"; +import { ATTACHMENTS_MIGRATIONS } from "./migrations.js"; +import { PgAttachmentsStore } from "./pg-store.js"; +import { LIVE_PG_ENABLED, createLiveSchema, type LiveSchema } from "./pg-live.test-harness.test.js"; + +function attachmentFixture(id: string, overrides: Partial = {}): Attachment { + return { + id, + filename: `${id}.pdf`, + s3Key: `uploads/${id}.pdf`, + bucket: "attachments-test", + size: 4_294_967_296, // > 2^32: proves BIGINT survives the round trip + contentType: "application/pdf", + link: null, + tag: null, + expiresAt: null, + createdAt: Date.now(), + storageBackend: "s3", + status: "ready", + encryptionAlgorithm: null, + encryptionSalt: null, + encryptionIv: null, + encryptionTag: null, + downloads: 0, + ...overrides, + }; +} + +describe.skipIf(!LIVE_PG_ENABLED)("PgAttachmentsStore against live PostgreSQL", () => { + let live: LiveSchema; + let store: PgAttachmentsStore; + + beforeAll(async () => { + live = await createLiveSchema("store"); + await new MigrationLedger(live.client, ATTACHMENTS_MIGRATIONS).migrate(); + store = new PgAttachmentsStore(live.client); + }, 60_000); + + afterAll(async () => { + await live?.drop(); + }); + + it("round-trips an attachment through insert, find, update and delete", async () => { + const attachment = attachmentFixture("att_roundtrip", { tag: "invoices" }); + await store.insert(attachment); + + const found = await store.findById(attachment.id); + expect(found).toEqual(attachment); + + await store.updateLink(attachment.id, "https://example.test/a/xyz", attachment.createdAt + 60_000); + await store.incrementDownloads(attachment.id); + const updated = await store.findById(attachment.id); + expect(updated?.link).toBe("https://example.test/a/xyz"); + expect(updated?.expiresAt).toBe(attachment.createdAt + 60_000); + expect(updated?.downloads).toBe(1); + + expect(await store.findAll({ tag: "invoices" })).toHaveLength(1); + expect(await store.findAll({ tag: "receipts" })).toHaveLength(0); + + await store.delete(attachment.id); + expect(await store.findById(attachment.id)).toBeNull(); + }); + + it("marks a pending upload ready without clobbering the stored content type", async () => { + const attachment = attachmentFixture("att_pending", { size: 0, status: "pending" }); + await store.insert(attachment); + + await store.markReady({ id: attachment.id, size: 2048, expiresAt: null }); + const ready = await store.findById(attachment.id); + expect(ready?.status).toBe("ready"); + expect(ready?.size).toBe(2048); + expect(ready?.contentType).toBe("application/pdf"); + + await store.delete(attachment.id); + }); + + it("hides expired rows from findAll and sweeps them on deleteExpired", async () => { + const expired = attachmentFixture("att_expired", { expiresAt: Date.now() - 60_000 }); + const live_ = attachmentFixture("att_live", { expiresAt: Date.now() + 3_600_000 }); + await store.insert(expired); + await store.insert(live_); + + const visible = (await store.findAll()).map((row) => row.id); + expect(visible).toContain(live_.id); + expect(visible).not.toContain(expired.id); + expect((await store.findAll({ includeExpired: true })).map((row) => row.id)).toContain(expired.id); + + expect(await store.deleteExpired()).toBe(1); + expect(await store.findById(expired.id)).toBeNull(); + expect(await store.findById(live_.id)).not.toBeNull(); + + await store.delete(live_.id); + }); + + it("enforces share-link max uses and releases a reserved use", async () => { + const attachment = attachmentFixture("att_share"); + await store.insert(attachment); + + const { shareLink, token } = await store.createShareLink({ + attachmentId: attachment.id, + expiresAt: null, + maxUses: 1, + allowedEmails: ["ops@example.test"], + }); + + const byToken = await store.findShareLinkByToken(token); + expect(byToken?.id).toBe(shareLink.id); + expect(byToken?.allowedEmails).toEqual(["ops@example.test"]); + expect(byToken?.requireEmail).toBe(false); + expect(await store.findShareLinkByToken("not-a-real-token")).toBeNull(); + + expect(await store.consumeShareLink(shareLink.id)).toBe(true); + expect(await store.consumeShareLink(shareLink.id)).toBe(false); // max_uses reached + expect(await store.releaseShareLink(shareLink.id)).toBe(true); + expect(await store.consumeShareLink(shareLink.id)).toBe(true); + + expect(await store.findShareLinksByAttachmentId(attachment.id)).toHaveLength(1); + + // The FK cascades, so deleting the attachment must take the link with it. + await store.delete(attachment.id); + expect(await store.findShareLinkByToken(token)).toBeNull(); + }); + + it("refuses to consume an expired share link", async () => { + const attachment = attachmentFixture("att_share_expired"); + await store.insert(attachment); + const { shareLink } = await store.createShareLink({ + attachmentId: attachment.id, + expiresAt: Date.now() - 1_000, + }); + + expect(await store.consumeShareLink(shareLink.id)).toBe(false); + await store.delete(attachment.id); + }); + + it("stores feedback using the server-side id and timestamp defaults", async () => { + await store.saveFeedback({ message: "gate works", email: null, category: "bug", version: "1.1.5" }); + + const row = await live.client.get<{ id: string; message: string; created_at: string }>( + `SELECT id, message, created_at FROM feedback WHERE message = $1`, + ["gate works"], + ); + expect(row?.id).toBeString(); + expect(row?.created_at).toBeString(); + }); +}); diff --git a/src/generated/storage-kit/.storage-kit-manifest.json b/src/generated/storage-kit/.storage-kit-manifest.json index 2bf0841..53ecb9b 100644 --- a/src/generated/storage-kit/.storage-kit-manifest.json +++ b/src/generated/storage-kit/.storage-kit-manifest.json @@ -1,14 +1,14 @@ { "generator": "@hasna/contracts vendor-kit", - "kitVersion": "0.4.1", + "kitVersion": "0.8.2", "files": { - "mode.ts": "sha256:b02ba62fdef2dc940068ab52c560ec0df19d451daadc1685ed56d5be841de5ab", - "tls.ts": "sha256:dc04078ca56a8d731080f5e7b508d7e8b4b918db9a6fe9adc6f464d716d2fb82", - "query.ts": "sha256:648a7b8299e6b21100377cbea7410d9909c337ca0235242af4ef89efe404d339", - "pool.ts": "sha256:906422231514a293ff4147a5651850345d4e51a9823305d7d13be62abba9cc5b", - "migrations.ts": "sha256:2c5d1ba736e128a8e6b8e889171c240ebf76ce34b682b8d289e9cd1ae76c13cc", - "health.ts": "sha256:43c5661eb95a4d28544b9ac371797852bb9b15c9be77a900975dc2b70848e56d", - "index.ts": "sha256:80900e5bb7092df6e5875e2a8da50902806cc0a36952bca27cb2cd6deaead1a5", + "mode.ts": "sha256:455046e716cc6840fd4fed7de2a47c11fab63164b132f251d035e83d6712c108", + "tls.ts": "sha256:2c5c733bd5c080d2a767e02f8fe27e0de580be42945c2070c9f0356b3a391a5e", + "query.ts": "sha256:13606bc6e8dca6d9b9ffc93c71010a85dffd38ce19f8ff38ef58363272624bb3", + "pool.ts": "sha256:db574c288ecaeacf9eebfef2600e4e0b44212fe674faea4e6627f02b7e1e7f0c", + "migrations.ts": "sha256:a39c1ed8e78cf50f3b74ba0270d974ff62373a4756521550ddfb7729d3efcbaf", + "health.ts": "sha256:7c7abf2b8765bd503f59d11e5e99a0bda5839c2f16d94728253bf672bfb2adf4", + "index.ts": "sha256:e03b32fee3df31f707840847f8f1fc58c388c7d90663cfb4946c536161055197", "README.md": "sha256:0886dbdf751597bfc8f23d2e72f233d6c3516b478925c8118b93d9d06a4eaa66" } } diff --git a/src/generated/storage-kit/health.ts b/src/generated/storage-kit/health.ts index 303d570..d20c8dd 100644 --- a/src/generated/storage-kit/health.ts +++ b/src/generated/storage-kit/health.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Health / readiness helpers for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/index.ts b/src/generated/storage-kit/index.ts index 57c4048..e8d3f6d 100644 --- a/src/generated/storage-kit/index.ts +++ b/src/generated/storage-kit/index.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Public surface of the vendored Hasna storage kit. @@ -13,7 +13,7 @@ // generator with `contracts vendor-kit --check`. Regenerate with // `bunx @hasna/contracts vendor-kit`. -export const KIT_VERSION = "0.4.1"; +export const KIT_VERSION = "0.8.2"; export * from "./mode.js"; export * from "./tls.js"; diff --git a/src/generated/storage-kit/migrations.ts b/src/generated/storage-kit/migrations.ts index 3b5b82c..acd1187 100644 --- a/src/generated/storage-kit/migrations.ts +++ b/src/generated/storage-kit/migrations.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Migration-ledger helper for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/mode.ts b/src/generated/storage-kit/mode.ts index 711c533..479654f 100644 --- a/src/generated/storage-kit/mode.ts +++ b/src/generated/storage-kit/mode.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Storage-mode resolution for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/pool.ts b/src/generated/storage-kit/pool.ts index 491416e..75fa59e 100644 --- a/src/generated/storage-kit/pool.ts +++ b/src/generated/storage-kit/pool.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Postgres pool factory for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/query.ts b/src/generated/storage-kit/query.ts index e59ee76..55c22ca 100644 --- a/src/generated/storage-kit/query.ts +++ b/src/generated/storage-kit/query.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // Typed query wrapper for the vendored Hasna storage kit. diff --git a/src/generated/storage-kit/tls.ts b/src/generated/storage-kit/tls.ts index 5e2ce35..ad86016 100644 --- a/src/generated/storage-kit/tls.ts +++ b/src/generated/storage-kit/tls.ts @@ -1,5 +1,5 @@ // @generated by @hasna/contracts vendor-kit — DO NOT EDIT. -// KIT_VERSION: 0.4.1 +// KIT_VERSION: 0.8.2 // Regenerate: bunx @hasna/contracts vendor-kit Verify (CI): contracts vendor-kit --check // TLS resolution for the vendored Hasna storage kit.