feat(db): 0006 构建/验收/审计域 + 追加式审计
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
CREATE TYPE "public"."audit_actor_type" AS ENUM('system', 'cli_user');--> statement-breakpoint
|
||||
CREATE TYPE "public"."build_artifact_kind" AS ENUM('dist', 'manifest', 'report', 'log');--> statement-breakpoint
|
||||
CREATE TYPE "public"."build_job_status" AS ENUM('queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale');--> statement-breakpoint
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"site_id" uuid,
|
||||
"actor_type" "audit_actor_type" NOT NULL,
|
||||
"actor_id" text,
|
||||
"actor_label" text NOT NULL,
|
||||
"command_id" text,
|
||||
"action" text NOT NULL,
|
||||
"object_type" text NOT NULL,
|
||||
"object_id" text NOT NULL,
|
||||
"before_value" jsonb,
|
||||
"after_value" jsonb,
|
||||
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "build_artifacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"build_job_id" uuid NOT NULL,
|
||||
"kind" "build_artifact_kind" NOT NULL,
|
||||
"storage_path" text NOT NULL,
|
||||
"sha256" text NOT NULL,
|
||||
"byte_size" bigint NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "build_input_snapshots" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"build_job_id" uuid NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"content_snapshot_hash" text NOT NULL,
|
||||
"route_snapshot_hash" text NOT NULL,
|
||||
"asset_manifest_hash" text NOT NULL,
|
||||
"site_config_revision_id" uuid,
|
||||
"theme_key" text NOT NULL,
|
||||
"theme_version" text NOT NULL,
|
||||
"theme_source_hash" text NOT NULL,
|
||||
"dependency_lock_hash" text NOT NULL,
|
||||
"source_commit" text NOT NULL,
|
||||
"node_version" text NOT NULL,
|
||||
"pnpm_version" text NOT NULL,
|
||||
"astro_version" text NOT NULL,
|
||||
"locale" text NOT NULL,
|
||||
"timezone" text NOT NULL,
|
||||
"build_timestamp" timestamp with time zone NOT NULL,
|
||||
"manifest_path" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "build_input_snapshots_build_job_id_unique" UNIQUE("build_job_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "build_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"status" "build_job_status" NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"attempt" integer DEFAULT 0 NOT NULL,
|
||||
"worker_id" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"heartbeat_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"error_code" text,
|
||||
"error_summary" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "build_jobs_idempotency_key_unique" UNIQUE("idempotency_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "validation_issues" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"validation_run_id" uuid NOT NULL,
|
||||
"severity" text NOT NULL,
|
||||
"rule" text NOT NULL,
|
||||
"url" text,
|
||||
"evidence" jsonb NOT NULL,
|
||||
"resolution_status" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "validation_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"site_id" uuid NOT NULL,
|
||||
"build_job_id" uuid,
|
||||
"rule_version" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "build_artifacts" ADD CONSTRAINT "build_artifacts_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "build_input_snapshots" ADD CONSTRAINT "build_input_snapshots_site_config_revision_id_site_config_revisions_id_fk" FOREIGN KEY ("site_config_revision_id") REFERENCES "public"."site_config_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "build_jobs" ADD CONSTRAINT "build_jobs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "validation_issues" ADD CONSTRAINT "validation_issues_validation_run_id_validation_runs_id_fk" FOREIGN KEY ("validation_run_id") REFERENCES "public"."validation_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "validation_runs" ADD CONSTRAINT "validation_runs_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "validation_runs" ADD CONSTRAINT "validation_runs_build_job_id_build_jobs_id_fk" FOREIGN KEY ("build_job_id") REFERENCES "public"."build_jobs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "build_artifacts_uq" ON "build_artifacts" USING btree ("build_job_id","kind","storage_path");--> statement-breakpoint
|
||||
-- ↓ 手写追加(drizzle-kit 不生成触发器):输入快照不可变 + 审计日志追加式(禁改禁删)。
|
||||
-- sf_forbid_update 为 0001 建立的共享函数,此处复用(其报错文案 "immutable table %: UPDATE forbidden"
|
||||
-- 同样覆盖 DELETE——触发器挂在 BEFORE UPDATE OR DELETE 上,两条路径走同一函数)。
|
||||
CREATE TRIGGER build_input_snapshots_immutable BEFORE UPDATE OR DELETE ON build_input_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION sf_forbid_update();--> statement-breakpoint
|
||||
CREATE TRIGGER audit_logs_immutable BEFORE UPDATE OR DELETE ON audit_logs
|
||||
FOR EACH ROW EXECUTE FUNCTION sf_forbid_update();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1792498347954,
|
||||
"tag": "0005_fine_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1792498960250,
|
||||
"tag": "0006_confused_lord_hawal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+104
-1
@@ -1,4 +1,4 @@
|
||||
// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*)
|
||||
// 聚合导出:Task 6 起按域追加 pgTable 定义(站点域 = 迁移文件 0000_pale_sumo;来源域 = 0001_*;内容域 = 0002_*;分类域 = 0003_*;媒体域 = 0004_*;路由域 = 0005_*;构建/验收/审计域 = 0006_*)
|
||||
import { pgTable, pgEnum, uuid, text, integer, bigint, jsonb, timestamp, uniqueIndex, index, boolean, primaryKey, type AnyPgColumn } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const sites = pgTable('sites', {
|
||||
@@ -254,3 +254,106 @@ export const routeRegistry = pgTable('route_registry', {
|
||||
// 会产出语义错误(默认 NULLS DISTINCT,纯路径路由两行 NULL 不冲突)的迁移。该索引由
|
||||
// 0005 迁移文件末尾手写 SQL 追加,集成测试 db.routes.test.ts 守住其存在与语义。
|
||||
})
|
||||
|
||||
// ---- 构建/验收/审计域(spec §5.7/§5.8;迁移文件 0006_*,末尾手写追加两不可变触发器)----
|
||||
|
||||
export const buildJobStatus = pgEnum('build_job_status', [
|
||||
'queued', 'snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale',
|
||||
])
|
||||
export const buildArtifactKind = pgEnum('build_artifact_kind', ['dist', 'manifest', 'report', 'log'])
|
||||
export const auditActorType = pgEnum('audit_actor_type', ['system', 'cli_user'])
|
||||
|
||||
export const buildJobs = pgTable('build_jobs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
siteId: uuid('site_id').notNull().references(() => sites.id),
|
||||
status: buildJobStatus('status').notNull(),
|
||||
// CLI 重试/断点续传靠同 key 幂等落库(同 import_jobs.idempotency_key 先例)
|
||||
idempotencyKey: text('idempotency_key').notNull().unique(),
|
||||
attempt: integer('attempt').notNull().default(0),
|
||||
workerId: text('worker_id'),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
errorCode: text('error_code'),
|
||||
errorSummary: text('error_summary'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// 构建输入快照:可复现性的单一事实来源(内容/路由/资产三 hash + 主题/工具链全版本钉死)。
|
||||
// 创建后不可变(0006 迁移手写触发器复用 sf_forbid_update);重试读同一行,永不改写。
|
||||
// build_job_id UNIQUE = 一 job 恰好一份输入快照(一对一)。
|
||||
export const buildInputSnapshots = pgTable('build_input_snapshots', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id).unique(),
|
||||
siteId: uuid('site_id').notNull().references(() => sites.id),
|
||||
contentSnapshotHash: text('content_snapshot_hash').notNull(),
|
||||
routeSnapshotHash: text('route_snapshot_hash').notNull(),
|
||||
assetManifestHash: text('asset_manifest_hash').notNull(),
|
||||
// 可空:极早期 job 可能无配置修订引用(FK 不强制非空)
|
||||
siteConfigRevisionId: uuid('site_config_revision_id').references(() => siteConfigRevisions.id),
|
||||
themeKey: text('theme_key').notNull(),
|
||||
themeVersion: text('theme_version').notNull(),
|
||||
themeSourceHash: text('theme_source_hash').notNull(),
|
||||
dependencyLockHash: text('dependency_lock_hash').notNull(),
|
||||
sourceCommit: text('source_commit').notNull(),
|
||||
nodeVersion: text('node_version').notNull(),
|
||||
pnpmVersion: text('pnpm_version').notNull(),
|
||||
astroVersion: text('astro_version').notNull(),
|
||||
locale: text('locale').notNull(),
|
||||
timezone: text('timezone').notNull(),
|
||||
buildTimestamp: timestamp('build_timestamp', { withTimezone: true }).notNull(),
|
||||
manifestPath: text('manifest_path').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const buildArtifacts = pgTable('build_artifacts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
buildJobId: uuid('build_job_id').notNull().references(() => buildJobs.id),
|
||||
kind: buildArtifactKind('kind').notNull(),
|
||||
storagePath: text('storage_path').notNull(),
|
||||
sha256: text('sha256').notNull(),
|
||||
byteSize: bigint('byte_size', { mode: 'number' }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
// 同一 job 内同 kind 同路径只登记一次(重试上传同一路径幂等);跨 job / 换 kind / 换路径 = 新行
|
||||
}, (t) => [uniqueIndex('build_artifacts_uq').on(t.buildJobId, t.kind, t.storagePath)])
|
||||
|
||||
// 验收 Gate 运行记录;build_job_id 可空(可对既有产物重跑 Gate,不必是新构建)
|
||||
export const validationRuns = pgTable('validation_runs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
siteId: uuid('site_id').notNull().references(() => sites.id),
|
||||
buildJobId: uuid('build_job_id').references(() => buildJobs.id),
|
||||
ruleVersion: text('rule_version').notNull(),
|
||||
status: text('status').notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// severity 用 text 而非 enum:级别集合随 Gate 规则演进(P0-P3 为当前切片约定),不值得一次 ALTER
|
||||
export const validationIssues = pgTable('validation_issues', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
validationRunId: uuid('validation_run_id').notNull().references(() => validationRuns.id),
|
||||
severity: text('severity').notNull(),
|
||||
rule: text('rule').notNull(),
|
||||
url: text('url'),
|
||||
evidence: jsonb('evidence').notNull(),
|
||||
resolutionStatus: text('resolution_status').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// 审计日志追加式(spec §5.8):只 INSERT,UPDATE/DELETE 由 0006 手写触发器拒绝。
|
||||
// site_id 可空——组织级操作(无站点上下文)记 NULL;CLI 必须带明确 Actor,不用模糊 system 顶替。
|
||||
export const auditLogs = pgTable('audit_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
siteId: uuid('site_id').references(() => sites.id),
|
||||
actorType: auditActorType('actor_type').notNull(),
|
||||
actorId: text('actor_id'),
|
||||
actorLabel: text('actor_label').notNull(),
|
||||
commandId: text('command_id'),
|
||||
action: text('action').notNull(),
|
||||
objectType: text('object_type').notNull(),
|
||||
objectId: text('object_id').notNull(),
|
||||
beforeValue: jsonb('before_value'),
|
||||
afterValue: jsonb('after_value'),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// 构建/验收/审计域集成测试(0006 迁移):build_jobs 幂等键唯一 + 状态 enum 八值、
|
||||
// build_input_snapshots 不可变触发器 + build_job_id UNIQUE、build_artifacts 三元组唯一、
|
||||
// audit_logs 追加式(UPDATE/DELETE 拒绝)、validation_issues.severity 可写。
|
||||
// 生命周期纪律:随机后缀插入 + finally 反序清理;两张不可变表(build_input_snapshots /
|
||||
// audit_logs)清场走点名 DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。
|
||||
// audit_logs 组织级行(site_id NULL,spec §5.8)无 site 可挂——清场按 fixture 唯一 command_id 圈定。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getDb } from '../../apps/api/src/db/client.js'
|
||||
|
||||
type Db = ReturnType<typeof getDb>
|
||||
type Fixture = {
|
||||
siteId: string
|
||||
buildJobId: string
|
||||
idempotencyKey: string
|
||||
snapshotId: string
|
||||
validationRunId: string
|
||||
auditCommandId: string
|
||||
}
|
||||
|
||||
// 建链(FK 依赖顺序):site → build_job → build_input_snapshot;validation_run 挂 site+job;
|
||||
// audit_logs 挂 site(站点级行;组织级 NULL 行在测试体内插,由 command_id 清场兜底)。
|
||||
async function setupJobsFixture(db: Db): Promise<Fixture> {
|
||||
const rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin)
|
||||
values (gen_random_uuid(), 'jobs-${rand}', 'JobsFixture', 'en', 'https://jobs.example') returning id`)) as any
|
||||
const siteId = site[0].id
|
||||
const job = (await db.execute(`insert into build_jobs (id, site_id, status, idempotency_key, attempt)
|
||||
values (gen_random_uuid(), '${siteId}', 'queued', 'build-idem-${rand}', 0) returning id, idempotency_key`)) as any
|
||||
const buildJobId = job[0].id
|
||||
const snap = (await db.execute(`insert into build_input_snapshots (id, build_job_id, site_id,
|
||||
content_snapshot_hash, route_snapshot_hash, asset_manifest_hash,
|
||||
theme_key, theme_version, theme_source_hash, dependency_lock_hash, source_commit,
|
||||
node_version, pnpm_version, astro_version, locale, timezone, build_timestamp, manifest_path)
|
||||
values (gen_random_uuid(), '${buildJobId}', '${siteId}',
|
||||
'content-hash-${rand}', 'route-hash-${rand}', 'asset-hash-${rand}',
|
||||
'sf-default', '1.0.0', 'theme-hash-${rand}', 'lock-hash-${rand}', 'commit-${rand}',
|
||||
'22.11.0', '10.7.1', '5.0.0', 'en', 'UTC', now(), 'manifest://${rand}.json') returning id`)) as any
|
||||
const run = (await db.execute(`insert into validation_runs (id, site_id, build_job_id, rule_version, status)
|
||||
values (gen_random_uuid(), '${siteId}', '${buildJobId}', 'gate@1', 'running') returning id`)) as any
|
||||
await db.execute(`insert into audit_logs (id, site_id, actor_type, actor_label, command_id, action, object_type, object_id)
|
||||
values (gen_random_uuid(), '${siteId}', 'cli_user', 'tester@siteforge', 'audit-${rand}', 'build.start', 'build_job', '${buildJobId}')`)
|
||||
return {
|
||||
siteId, buildJobId, idempotencyKey: job[0].idempotency_key, snapshotId: snap[0].id,
|
||||
validationRunId: run[0].id, auditCommandId: `audit-${rand}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 反序清理:子表常规删除;两张不可变表逐一 DISABLE → 删 → ENABLE(嵌套 finally);
|
||||
// audit_logs 按 fixture 唯一 command_id 删(含组织级 NULL site 行);最后删 site。
|
||||
async function cleanupJobsFixture(db: Db, f: Fixture) {
|
||||
await db.execute(`delete from validation_issues where validation_run_id = '${f.validationRunId}'`)
|
||||
await db.execute(`delete from validation_runs where site_id = '${f.siteId}'`)
|
||||
await db.execute(`delete from build_artifacts where build_job_id = '${f.buildJobId}'`)
|
||||
await db.execute(`alter table build_input_snapshots disable trigger build_input_snapshots_immutable`)
|
||||
try {
|
||||
await db.execute(`delete from build_input_snapshots where site_id = '${f.siteId}'`)
|
||||
} finally {
|
||||
await db.execute(`alter table build_input_snapshots enable trigger build_input_snapshots_immutable`)
|
||||
}
|
||||
await db.execute(`alter table audit_logs disable trigger audit_logs_immutable`)
|
||||
try {
|
||||
await db.execute(`delete from audit_logs where command_id = '${f.auditCommandId}'`)
|
||||
} finally {
|
||||
await db.execute(`alter table audit_logs enable trigger audit_logs_immutable`)
|
||||
}
|
||||
await db.execute(`delete from build_jobs where site_id = '${f.siteId}'`)
|
||||
await db.execute(`delete from sites where id = '${f.siteId}'`)
|
||||
}
|
||||
|
||||
async function withJobsFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
|
||||
const db = getDb()
|
||||
const f = await setupJobsFixture(db)
|
||||
try {
|
||||
await fn(db, f)
|
||||
} finally {
|
||||
await cleanupJobsFixture(db, f)
|
||||
await (db as any).$client.end()
|
||||
}
|
||||
}
|
||||
|
||||
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
|
||||
// 服务端原始错误文本在 cause 里——断言触发器/约束/枚举身份时必须看 cause。
|
||||
async function rejectionMessage(db: Db, query: string): Promise<string> {
|
||||
try {
|
||||
await db.execute(query)
|
||||
} catch (e: any) {
|
||||
return String(e?.cause?.message ?? e?.message ?? e)
|
||||
}
|
||||
throw new Error(`expected query to be rejected: ${query}`)
|
||||
}
|
||||
|
||||
// 不可变快照探测插入:换新 id 但复用 fixture 的 build_job_id(唯一键探测用)。
|
||||
function snapshotProbe(f: Fixture, buildJobId: string) {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
return `insert into build_input_snapshots (id, build_job_id, site_id,
|
||||
content_snapshot_hash, route_snapshot_hash, asset_manifest_hash,
|
||||
theme_key, theme_version, theme_source_hash, dependency_lock_hash, source_commit,
|
||||
node_version, pnpm_version, astro_version, locale, timezone, build_timestamp, manifest_path)
|
||||
values (gen_random_uuid(), '${buildJobId}', '${f.siteId}',
|
||||
'content-hash-${suffix}', 'route-hash-${suffix}', 'asset-hash-${suffix}',
|
||||
'sf-default', '1.0.0', 'theme-hash-${suffix}', 'lock-hash-${suffix}', 'commit-${suffix}',
|
||||
'22.11.0', '10.7.1', '5.0.0', 'en', 'UTC', now(), 'manifest://${suffix}.json')`
|
||||
}
|
||||
|
||||
describe('构建/验收/审计域约束', () => {
|
||||
it('build_jobs.idempotency_key 唯一:同 key 第二条拒绝', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
expect(await rejectionMessage(db, `insert into build_jobs (id, site_id, status, idempotency_key, attempt)
|
||||
values (gen_random_uuid(), '${f.siteId}', 'queued', '${f.idempotencyKey}', 0)`))
|
||||
.toMatch(/build_jobs_idempotency_key_unique/)
|
||||
})
|
||||
})
|
||||
|
||||
it('build_jobs.status enum 八值(queued/snapshotting/building/validating/succeeded/failed/cancelled/stale)可写往返,第九值拒绝', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
for (const status of ['snapshotting', 'building', 'validating', 'succeeded', 'failed', 'cancelled', 'stale', 'queued']) {
|
||||
await db.execute(`update build_jobs set status = '${status}' where id = '${f.buildJobId}'`)
|
||||
const row = (await db.execute(`select status from build_jobs where id = '${f.buildJobId}'`)) as any
|
||||
expect(row[0].status).toBe(status)
|
||||
}
|
||||
// enum 在库:类型化列拒绝八值之外的任意字符串
|
||||
expect(await rejectionMessage(db, `update build_jobs set status = 'bogus' where id = '${f.buildJobId}'`))
|
||||
.toMatch(/build_job_status/)
|
||||
})
|
||||
})
|
||||
|
||||
it('build_input_snapshots 不可变:UPDATE 与 DELETE 均被触发器拒绝', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
expect(await rejectionMessage(db, `update build_input_snapshots set theme_version = '9.9.9' where id = '${f.snapshotId}'`))
|
||||
.toMatch(/immutable table build_input_snapshots/)
|
||||
expect(await rejectionMessage(db, `delete from build_input_snapshots where id = '${f.snapshotId}'`))
|
||||
.toMatch(/immutable table build_input_snapshots/)
|
||||
})
|
||||
})
|
||||
|
||||
it('build_input_snapshots.build_job_id UNIQUE:同 job 第二条快照拒绝', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
expect(await rejectionMessage(db, snapshotProbe(f, f.buildJobId)))
|
||||
.toMatch(/build_input_snapshots_build_job_id_unique/)
|
||||
})
|
||||
})
|
||||
|
||||
it('build_artifacts UNIQUE(build_job_id, kind, storage_path):重复拒绝,换 kind 放行', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
const artifact = (kind: string, path: string) =>
|
||||
`insert into build_artifacts (id, build_job_id, kind, storage_path, sha256, byte_size)
|
||||
values (gen_random_uuid(), '${f.buildJobId}', '${kind}', '${path}', 'sha256-${Math.random().toString(36).slice(2, 8)}', 100)`
|
||||
await db.execute(artifact('dist', 'build://out/dist.zip'))
|
||||
// 同 job 同 kind 同路径(如重试上传同一路径)→ 唯一索引拒绝
|
||||
expect(await rejectionMessage(db, artifact('dist', 'build://out/dist.zip')))
|
||||
.toMatch(/build_artifacts_uq/)
|
||||
// 对照组:同路径换 kind → 允许(log 与 dist 可以同路径前缀语义,约束打在三元组上),由 finally 清理
|
||||
await db.execute(artifact('log', 'build://out/dist.zip'))
|
||||
})
|
||||
})
|
||||
|
||||
it('audit_logs 追加式:UPDATE 与 DELETE 均被触发器拒绝;组织级行(site_id NULL)可写入', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
// spec §5.8:组织级操作无站点上下文,site_id 为 NULL 也必须可记
|
||||
const orgRow = (await db.execute(`insert into audit_logs (id, site_id, actor_type, actor_label, command_id, action, object_type, object_id)
|
||||
values (gen_random_uuid(), null, 'system', 'siteforge-scheduler', '${f.auditCommandId}', 'org.gc', 'organization', 'global') returning id`)) as any
|
||||
const siteRow = (await db.execute(`select id from audit_logs where site_id = '${f.siteId}' and command_id = '${f.auditCommandId}'`)) as any
|
||||
for (const rowId of [orgRow[0].id, siteRow[0].id]) {
|
||||
expect(await rejectionMessage(db, `update audit_logs set action = 'tampered' where id = '${rowId}'`))
|
||||
.toMatch(/immutable table audit_logs/)
|
||||
expect(await rejectionMessage(db, `delete from audit_logs where id = '${rowId}'`))
|
||||
.toMatch(/immutable table audit_logs/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('validation_issues.severity 列存在可写:P0/P1/P2/P3 text 往返', async () => {
|
||||
await withJobsFixture(async (db, f) => {
|
||||
const issue = (await db.execute(`insert into validation_issues (id, validation_run_id, severity, rule, url, evidence, resolution_status)
|
||||
values (gen_random_uuid(), '${f.validationRunId}', 'P0', 'route.unresolved', '/probe', '[]', 'open') returning id`)) as any
|
||||
const issueId = issue[0].id
|
||||
// severity 是 text 而非 enum:四个级别逐一可写、原样读回(不折叠大小写、不做映射)
|
||||
for (const severity of ['P1', 'P2', 'P3', 'P0']) {
|
||||
await db.execute(`update validation_issues set severity = '${severity}' where id = '${issueId}'`)
|
||||
const row = (await db.execute(`select severity from validation_issues where id = '${issueId}'`)) as any
|
||||
expect(row[0].severity).toBe(severity)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user