190 lines
8.9 KiB
JavaScript
190 lines
8.9 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import { formatStagingLiveUatReadinessResult, verifyStagingLiveUatReadiness } from "./verify-staging-live-uat-readiness.mjs";
|
|
|
|
const fixtures = [];
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
const VALID_STAGING_DOC = [
|
|
"# Staging, Live, and UAT Readiness",
|
|
"",
|
|
"## Scope",
|
|
"Manual runtime and provider-console evidence stays separate from automatic verification.",
|
|
"",
|
|
"## Readiness stages",
|
|
"Web, API, worker, Postgres database, Redis, S3 storage, Mailjet, Stripe, and ClamAV checks are staged.",
|
|
"",
|
|
"## Local static gates",
|
|
"Run typecheck, lint, verify:ci-baseline, verify:env-drift, verify:health-baseline, verify:openapi-security, verify:db-migrate-status, verify:deploy-workflow, verify:release-docs, verify:docs-links, and verify:staging-live-uat-readiness.",
|
|
"",
|
|
"## Staging environment contract",
|
|
"Record target labels for web, API, worker, database, Redis, storage, Mailjet, Stripe, and ClamAV.",
|
|
"",
|
|
"## Runtime smoke records",
|
|
"Use request ID, job ID, queue name, and redacted diagnostic labels for provider runtime smoke records.",
|
|
"",
|
|
"## Code-gap tracking before live confidence",
|
|
"Track API-key cutover, storage lifecycle, Imports final submit, Retention operations, legacy API migration, and compatibility boundary decisions.",
|
|
"",
|
|
"## Browser UAT matrix",
|
|
"Browser UAT covers MFA, onboarding, and review-before-apply journeys manually.",
|
|
"",
|
|
"## Final evidence packet",
|
|
"Collect local gates, runtime smoke status, browser UAT status, rollback class, and redaction review.",
|
|
"",
|
|
"## Redaction boundary",
|
|
"Never record raw secrets, customer data, provider payloads, or credential URLs.",
|
|
"",
|
|
"## Verification",
|
|
"Run `pnpm run verify:staging-live-uat-readiness`.",
|
|
].join("\n");
|
|
|
|
const SUPPORTING_DOCS = {
|
|
"docs/production-checklist.md": "# Production Checklist\n\nManual runtime checks and redaction rules.",
|
|
"docs/release-checklist.md": "# Release Checklist\n\nManual runtime evidence remains redacted.",
|
|
"docs/env-vars.md": "# Environment Variables\n\nUse key names only.",
|
|
"docs/deployment.md": "# Deployment\n\nDeployment target labels only.",
|
|
"docs/database.md": "# Database\n\nMigration and backup evidence uses labels.",
|
|
"docs/worker.md": "# Worker\n\nQueue and job evidence is redacted.",
|
|
"docs/mail.md": "# Mail\n\nMailjet status is recorded without payloads.",
|
|
"docs/security.md": "# Security\n\nRedaction boundary.",
|
|
"docs/observability.md": "# Observability\n\nRequest ID and job ID correlation.",
|
|
};
|
|
|
|
const VALID_PACKAGE_JSON = {
|
|
scripts: {
|
|
typecheck: "tsc -b",
|
|
lint: "eslint .",
|
|
"verify:ci-baseline": "node scripts/verify-ci-baseline.mjs",
|
|
"verify:env-drift": "node scripts/check-env-drift.mjs",
|
|
"verify:health-baseline": "node scripts/verify-health-baseline.mjs",
|
|
"verify:openapi-security": "tsx scripts/openapi-guard.ts",
|
|
"verify:db-migrate-status": "node scripts/verify-db-migrate-status.mjs",
|
|
"verify:deploy-workflow": "node scripts/verify-gitea-deploy-workflow.mjs",
|
|
"verify:release-docs": "node scripts/verify-release-docs.mjs",
|
|
"verify:docs-links": "tsx scripts/docs-link-check.ts",
|
|
"verify:staging-live-uat-readiness": "node scripts/verify-staging-live-uat-readiness.mjs",
|
|
},
|
|
};
|
|
|
|
const SUPPORTING_CODE = {
|
|
"packages/db/src/api-keys.server.ts": "export function findLegacyApiKeyByKey() { const allowPlaintextFallback = true; const keyHash = 'hash'; return { allowPlaintextFallback, keyHash }; }\n",
|
|
"packages/config/src/catalog.ts": "export const flag = 'API_KEY_LEGACY_PLAINTEXT_FALLBACK';\n",
|
|
"packages/storage/src/access/index.ts": "export function authorizeStorageUploadConstraints() { return ['unsupported_content_type', 'object_too_large']; }\n",
|
|
"apps/web/app/routes/app.$tenant/internal/imports/submit.tsx": "import { findImportJobForInspection } from '@toolctrl/db/import-jobs.server'; import { buildImportJob, enqueueJob } from '@toolctrl/queue'; export { findImportJobForInspection, buildImportJob, enqueueJob };\n",
|
|
"apps/api/src/exportRetentionQueue.ts": "export function queueExportArtifactRetention() { return ['expired-export-artifacts', queueNames.maintenance]; }\n",
|
|
"apps/api/src/routes/maintenance-routes.ts": "app.post('/v1/maintenance/export-retention/enqueue', handler); authorizeStorageUploadConstraints();\n",
|
|
"apps/api/src/openapi.ts": "const operationId = 'enqueueExportArtifactRetention'; const code = 'maintenance_queue_unavailable';\n",
|
|
"apps/worker/src/handlers.ts": "if (payload.task === 'expired-export-artifacts') listPending({ take: payload.take ?? payload.olderThanHours });\n",
|
|
};
|
|
|
|
function writeFixtureFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function createFixture(overrides = {}) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "staging-live-uat-"));
|
|
fixtures.push(rootDir);
|
|
|
|
writeFixtureFile(rootDir, "docs/staging-live-uat.md", overrides["docs/staging-live-uat.md"] ?? VALID_STAGING_DOC);
|
|
for (const [relativePath, content] of Object.entries(SUPPORTING_DOCS)) {
|
|
if (overrides[relativePath] === null) continue;
|
|
writeFixtureFile(rootDir, relativePath, overrides[relativePath] ?? content);
|
|
}
|
|
for (const [relativePath, content] of Object.entries(SUPPORTING_CODE)) {
|
|
if (overrides[relativePath] === null) continue;
|
|
writeFixtureFile(rootDir, relativePath, overrides[relativePath] ?? content);
|
|
}
|
|
|
|
writeFixtureFile(rootDir, "package.json", JSON.stringify(overrides["package.json"] ?? VALID_PACKAGE_JSON, null, 2));
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-staging-live-uat-readiness", () => {
|
|
it("passes for a complete readiness runbook and package script contract", () => {
|
|
const rootDir = createFixture();
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
const output = formatStagingLiveUatReadinessResult(result);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.match(output, /^PASS staging\/live\/UAT readiness verification/);
|
|
assert.match(output, /10 readiness documentation files/);
|
|
assert.match(output, /11 package scripts/);
|
|
assert.match(output, /8 code evidence files/);
|
|
});
|
|
|
|
it("fails when the staging runbook is missing a required section", () => {
|
|
const rootDir = createFixture({
|
|
"docs/staging-live-uat.md": VALID_STAGING_DOC.replace("## Browser UAT matrix", "## Browser checks"),
|
|
});
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /missing required section heading "## Browser UAT matrix"/);
|
|
});
|
|
|
|
it("fails when a required local readiness package script is missing", () => {
|
|
const packageJson = JSON.parse(JSON.stringify(VALID_PACKAGE_JSON));
|
|
delete packageJson.scripts["verify:db-migrate-status"];
|
|
const rootDir = createFixture({ "package.json": packageJson });
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /missing required readiness script "verify:db-migrate-status"/);
|
|
});
|
|
|
|
it("fails when the health baseline readiness script is missing", () => {
|
|
const packageJson = JSON.parse(JSON.stringify(VALID_PACKAGE_JSON));
|
|
delete packageJson.scripts["verify:health-baseline"];
|
|
const rootDir = createFixture({ "package.json": packageJson });
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /missing required readiness script "verify:health-baseline"/);
|
|
});
|
|
|
|
it("fails when staging-blocking code evidence is missing", () => {
|
|
const rootDir = createFixture({
|
|
"apps/web/app/routes/app.$tenant/internal/imports/submit.tsx": null,
|
|
});
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /app\/routes\/app\.\$tenant\/internal\/imports\/submit\.tsx: missing required code evidence/);
|
|
});
|
|
|
|
it("rejects automatic browser test commands in readiness docs", () => {
|
|
const rootDir = createFixture({
|
|
"docs/staging-live-uat.md": `${VALID_STAGING_DOC}\n\nRun pnpm exec playwright test for browser proof.\n`,
|
|
});
|
|
|
|
const result = verifyStagingLiveUatReadiness(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /forbidden automatic browser\/E2E command pattern "playwright-test-command"/);
|
|
});
|
|
|
|
it("runs as a CLI and prints a passing summary for the repository", () => {
|
|
const output = execFileSync(process.execPath, ["scripts/verify-staging-live-uat-readiness.mjs"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.match(output, /^PASS staging\/live\/UAT readiness verification/);
|
|
});
|
|
});
|