From 5ca7c6909658e77e339f20db1f69ac0b39b25aab Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 27 Jul 2026 23:02:50 +0300 Subject: [PATCH 1/3] feat: Align open-attachments with @hasna/contracts --- hasna.contract.json | 67 +++++++++++++++++++++++++++++++--------- package.json | 4 ++- pnpm-workspace.yaml | 1 + scripts/scan-artifact.ts | 36 +++++++++++++++++++++ 4 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 scripts/scan-artifact.ts diff --git a/hasna.contract.json b/hasna.contract.json index 4c4e701..9989fe0 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.3", "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 HASNA_ATTACHMENTS_STORAGE_MODE=cloud bun test src/serve 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..023c6cc 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", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3158760..51857e9 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.3' diff --git a/scripts/scan-artifact.ts b/scripts/scan-artifact.ts new file mode 100644 index 0000000..d57c27f --- /dev/null +++ b/scripts/scan-artifact.ts @@ -0,0 +1,36 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +const CONTRACTS_KIT_VERSION = "0.8.3"; + +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; +} + +function scannerCommand(archive: string): string[] { + const override = process.env.HASNA_CONTRACTS_ARTIFACT_SCAN?.trim(); + if (override) return [...override.split(/\s+/), archive]; + return ["bunx", `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, "artifact-scan", archive]; +} + +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); + console.log(run(scannerCommand(archive), repoRoot)); +} finally { + rmSync(workspace, { recursive: true, force: true }); +} From afe2be57ce13ec104736238fc474ccef77af52da Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 28 Jul 2026 04:31:17 +0300 Subject: [PATCH 2/3] fix(release): pin the artifact-scan gate to a published kit and prove it in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan:artifact gate was pinned to @hasna/contracts@0.8.3, which is not published (latest is 0.8.2), so `bun run scan:artifact` exited 1 — and since verify:release ends with it and prepack/prepublishOnly run verify:release, every pack and publish of @hasna/attachments failed. Pin the scanner, the contract manifest, the vendored storage kit and the dependency range to 0.8.2 so they all agree instead of the manifest asserting an alignment the repo did not have. - scripts/scan-artifact.ts: pin CONTRACTS_KIT_VERSION to the published 0.8.2, drop the HASNA_CONTRACTS_ARTIFACT_SCAN override (it replaced the scanner wholesale, so `HASNA_CONTRACTS_ARTIFACT_SCAN=true bun publish` shipped with the gate silently disabled), echo the resolved scanner command so a no-op scan is visible in publish logs, and export the pieces behind an import.meta.main guard so the gate is testable. - hasna.contract.json / package.json / bun.lock / pnpm-workspace.yaml: move kitVersion, the @hasna/contracts range and the release-age exclusion to 0.8.2; regenerate src/generated/storage-kit with `bunx @hasna/contracts@0.8.2 vendor-kit` (content is byte-identical to the 0.4.1 kit — only the version stamps move), so `contracts vendor-kit --check` is consistent again. - scripts/scan-artifact.test.ts + scripts/test.sh: the suite globbed only src/ and sdk/, so nothing this change touches was covered. Collect scripts/ too and add tests that packs the artifact, run the pinned scanner, assert there is no env bypass, and assert the kit version stays in lockstep across the manifest, the vendored kit and the dependency range. Verified: bash scripts/test.sh -> 51 total, 51 passed, 0 failed (1932 expect() calls); bun run verify:release -> exit 0 with `pass artifact-scan hasna-attachments-1.1.5.tgz (packed_artifact, 11 members scanned, 0 excluded, 0 unreadable)`; bunx @hasna/contracts@0.8.2 repo-conformance . -> ok, published_artifact_gate pass; vendor-kit --check ok at 0.8.2. --- bun.lock | 4 +- hasna.contract.json | 2 +- package.json | 2 +- pnpm-workspace.yaml | 2 +- scripts/scan-artifact.test.ts | 55 +++++++++++++++++++ scripts/scan-artifact.ts | 48 ++++++++++++---- scripts/test.sh | 2 +- .../storage-kit/.storage-kit-manifest.json | 16 +++--- src/generated/storage-kit/health.ts | 2 +- src/generated/storage-kit/index.ts | 4 +- src/generated/storage-kit/migrations.ts | 2 +- src/generated/storage-kit/mode.ts | 2 +- src/generated/storage-kit/pool.ts | 2 +- src/generated/storage-kit/query.ts | 2 +- src/generated/storage-kit/tls.ts | 2 +- 15 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 scripts/scan-artifact.test.ts 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 9989fe0..82972bc 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -3,7 +3,7 @@ "name": "attachments", "class": "cli-with-store", "contractVersion": "v1", - "kitVersion": "0.8.3", + "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"], diff --git a/package.json b/package.json index 023c6cc..a230ab9 100644 --- a/package.json +++ b/package.json @@ -65,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 51857e9..4c7f226 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ minimumReleaseAgeExclude: - '@hasna/contracts@0.4.1' - '@hasna/contracts@0.5.2' - - '@hasna/contracts@0.8.3' + - '@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..f628e2e --- /dev/null +++ b/scripts/scan-artifact.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach } 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 readJson(relativePath: string): Record { + return JSON.parse(readFileSync(join(repoRoot, relativePath), "utf8")); +} + +/** Strip a leading range operator so "^0.8.2" and "0.8.2" compare equal. */ +function rangeBaseVersion(range: string): string { + return range.replace(/^[\^~=v]+/, ""); +} + +describe("scan:artifact release gate", () => { + afterEach(() => { + delete process.env.HASNA_CONTRACTS_ARTIFACT_SCAN; + }); + + it("always resolves to the pinned scanner, with no environment bypass", () => { + // A gate that any env var can replace at publish time is not a gate. + process.env.HASNA_CONTRACTS_ARTIFACT_SCAN = "true"; + expect(scannerCommand("/tmp/pkg.tgz")).toEqual([ + "bunx", + `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, + "artifact-scan", + "/tmp/pkg.tgz", + ]); + }); + + 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(readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8")).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 index d57c27f..00b6968 100644 --- a/scripts/scan-artifact.ts +++ b/scripts/scan-artifact.ts @@ -1,8 +1,23 @@ +#!/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"; -const CONTRACTS_KIT_VERSION = "0.8.3"; +export const CONTRACTS_KIT_VERSION = "0.8.2"; function run(command: string[], cwd: string): string { const result = Bun.spawnSync(command, { @@ -18,19 +33,28 @@ function run(command: string[], cwd: string): string { return stdout; } -function scannerCommand(archive: string): string[] { - const override = process.env.HASNA_CONTRACTS_ARTIFACT_SCAN?.trim(); - if (override) return [...override.split(/\s+/), archive]; +export function scannerCommand(archive: string): string[] { return ["bunx", `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, "artifact-scan", archive]; } -const repoRoot = join(import.meta.dir, ".."); -const workspace = mkdtempSync(join(tmpdir(), "attachments-artifact-scan-")); +/** 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 }); + } +} -try { - const packed = run(["bun", "pm", "pack", "--destination", workspace, "--ignore-scripts", "--quiet"], repoRoot); - const archive = isAbsolute(packed) ? packed : join(workspace, packed); - console.log(run(scannerCommand(archive), 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/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. From bb9c3dd00e4897beae6747413041f7589f1b50ab Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 28 Jul 2026 05:00:00 +0300 Subject: [PATCH 3/3] test(release): make the artifact and live-PG gates fail when they are broken The gates this PR adds were not guarded by anything that could go red. - scan-artifact.test.ts asserted scannerCommand() against a poked env var the implementation never reads, so a real `process.env` bypass kept it green. Assert the invariant the module header claims instead: scan-artifact.ts has no environment input path at all. - Nothing asserted that prepack still reaches scan:artifact, so the whole deliverable could be deleted with a green suite. Walk the package.json script graph the same way `contracts repo-conformance` does, and pin the declared script name in hasna.contract.json to it. - storage.pgTestGate pointed at `bun test src/serve src/db`: every src/serve test runs on InMemoryAttachmentsStore and src/db had no test files, so the gate returned 40 pass against a closed port. Add live-PostgreSQL coverage for ATTACHMENTS_MIGRATIONS (including the hasna_auth_0003 api_keys.tid column the @hasna/contracts bump introduces) and PgAttachmentsStore, each run isolated in its own schema, and point the gate at them. The command now fails when the database URL is unset and when the database is unreachable. - Add CI so repo-conformance, the release gate and the live-PG gate run on every push instead of only when a reviewer types them, including a step that proves the live-PG gate still fails against a dead database. --- .github/workflows/ci.yml | 89 ++++++++++++++++ hasna.contract.json | 2 +- scripts/scan-artifact.test.ts | 95 +++++++++++++++-- src/db/migrations.pg.test.ts | 139 ++++++++++++++++++++++++ src/db/pg-live.test-harness.test.ts | 148 ++++++++++++++++++++++++++ src/db/pg-store.pg.test.ts | 158 ++++++++++++++++++++++++++++ 6 files changed, 619 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/db/migrations.pg.test.ts create mode 100644 src/db/pg-live.test-harness.test.ts create mode 100644 src/db/pg-store.pg.test.ts 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/hasna.contract.json b/hasna.contract.json index 82972bc..42c8241 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -15,7 +15,7 @@ "aliasEnvPrefix": "ATTACHMENTS_", "pgTestGate": { "envVar": "HASNA_ATTACHMENTS_TEST_DATABASE_URL", - "command": "HASNA_ATTACHMENTS_DATABASE_URL=$HASNA_ATTACHMENTS_TEST_DATABASE_URL HASNA_ATTACHMENTS_STORAGE_MODE=cloud bun test src/serve src/db" + "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" } }, "serviceSurfaces": [ diff --git a/scripts/scan-artifact.test.ts b/scripts/scan-artifact.test.ts index f628e2e..13d713d 100644 --- a/scripts/scan-artifact.test.ts +++ b/scripts/scan-artifact.test.ts @@ -1,12 +1,21 @@ -import { describe, it, expect, afterEach } from "bun:test"; +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(readFileSync(join(repoRoot, relativePath), "utf8")); + 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. */ @@ -14,14 +23,49 @@ 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", () => { - afterEach(() => { - delete process.env.HASNA_CONTRACTS_ARTIFACT_SCAN; - }); + 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["']/); - it("always resolves to the pinned scanner, with no environment bypass", () => { - // A gate that any env var can replace at publish time is not a gate. - process.env.HASNA_CONTRACTS_ARTIFACT_SCAN = "true"; expect(scannerCommand("/tmp/pkg.tgz")).toEqual([ "bunx", `@hasna/contracts@${CONTRACTS_KIT_VERSION}`, @@ -30,6 +74,37 @@ describe("scan:artifact release gate", () => { ]); }); + 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( @@ -39,9 +114,7 @@ describe("scan:artifact release gate", () => { CONTRACTS_KIT_VERSION, ); // The pinned version must be quarantine-excluded or a fresh install stalls. - expect(readFileSync(join(repoRoot, "pnpm-workspace.yaml"), "utf8")).toContain( - `'@hasna/contracts@${CONTRACTS_KIT_VERSION}'`, - ); + expect(readText("pnpm-workspace.yaml")).toContain(`'@hasna/contracts@${CONTRACTS_KIT_VERSION}'`); }); it("packs the artifact and passes the scan with the pinned kit", () => { 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(); + }); +});