- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
215 lines
8.3 KiB
TypeScript
215 lines
8.3 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { z } from "zod";
|
|
import * as C from "../src/index.ts";
|
|
|
|
// 与 packages/db/src/types.ts(从迁移反射生成)做文本级对照:
|
|
// 1) 每张表的列 ⇔ 对应 Row schema 的字段(camelCase),只允许显式列出的差异;
|
|
// 2) 每个 CHECK 枚举 ⇔ contracts 中对应 z.enum 的取值。
|
|
// 只读文件,不引入 @umt/db 依赖。
|
|
|
|
const typesSrc = readFileSync(new URL("../../db/src/types.ts", import.meta.url), "utf8");
|
|
|
|
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
|
|
function tableColumns(): Map<string, string[]> {
|
|
const out = new Map<string, string[]>();
|
|
for (const m of typesSrc.matchAll(/export interface (\w+)Table \{\n([\s\S]*?)\n\}/g)) {
|
|
const cols = [...m[2]!.matchAll(/^ {2}(\w+):/gm)].map((x) => x[1]!);
|
|
out.set(m[1]!, cols);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function enumAliases(): Map<string, string[]> {
|
|
const out = new Map<string, string[]>();
|
|
for (const m of typesSrc.matchAll(/export type (\w+) =([^;]+);/g)) {
|
|
if (m[1] === "Timestamptz" || m[1] === "Json" || m[2]!.includes("<")) continue;
|
|
out.set(
|
|
m[1]!,
|
|
[...m[2]!.matchAll(/"([^"]*)"/g)].map((x) => x[1]!),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
type Obj = z.ZodObject;
|
|
|
|
/** 表(PascalCase)→ Row schema 与允许的差异 */
|
|
const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }> = {
|
|
AcquisitionPlan: { row: C.AcquisitionPlan },
|
|
AcquisitionSlot: { row: C.AcquisitionSlot },
|
|
AgentRun: { row: C.AgentRun },
|
|
AgentRunEvent: { row: C.AgentRunEventRow },
|
|
ApiKey: { row: C.ApiKey, dbOnly: ["keyHash"] },
|
|
Approval: { row: C.Approval },
|
|
Artifact: { row: C.Artifact },
|
|
AssetLocation: { row: C.AssetLocation },
|
|
AssetOperation: { row: C.AssetOperation },
|
|
AssetUnitCoverage: { row: C.AssetUnitCoverage },
|
|
AuditEvent: { row: C.AuditEvent },
|
|
AutomationGrant: { row: C.AutomationGrant },
|
|
ContentUnit: { row: C.ContentUnit },
|
|
Decision: { row: C.Decision },
|
|
DownloadTask: { row: C.DownloadTask },
|
|
DownloaderInstance: { row: C.DownloaderInstance },
|
|
ExternalIdentity: { row: C.ExternalIdentity },
|
|
FeedSource: { row: C.FeedSource },
|
|
IdentityRevision: { row: C.IdentityRevision },
|
|
LibraryRoot: { row: C.LibraryRoot },
|
|
MediaAsset: { row: C.MediaAsset },
|
|
MediaEntity: { row: C.MediaEntity },
|
|
MediaServerInstance: { row: C.MediaServerInstance },
|
|
Observation: { row: C.Observation },
|
|
OrganizationFileResult: { row: C.OrganizationFileResult },
|
|
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
|
|
OutboxEvent: { row: C.OutboxEvent },
|
|
PathBinding: { row: C.PathBinding },
|
|
PathMapping: { row: C.PathMapping },
|
|
PolicyRevision: { row: C.PolicyRevision },
|
|
PreferenceSuppression: { row: C.PreferenceSuppression },
|
|
PreferenceTag: { row: C.PreferenceTag },
|
|
ReconcileRun: { row: C.ReconcileRun },
|
|
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
|
|
ServerMapping: { row: C.ServerMapping },
|
|
Settings: { row: C.SettingEntry },
|
|
Site: { row: C.Site },
|
|
SiteAccount: { row: C.SiteAccount },
|
|
SourceRelease: { row: C.SourceRelease },
|
|
Subscription: { row: C.Subscription },
|
|
Task: { row: C.Task },
|
|
TitleAlias: { row: C.TitleAlias },
|
|
};
|
|
|
|
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
|
|
const INTERNAL_TABLES = [
|
|
"TasksRunMeta",
|
|
"TasksFact",
|
|
"TasksOpLedger",
|
|
// W2-A 登录门禁与 SSE 总线的服务端内部表:经 contracts/auth.ts 的专用 DTO 暴露,不做 Row 对照
|
|
"AdminUser",
|
|
"AdminSession",
|
|
"LoginAttempt",
|
|
"SseEvent",
|
|
];
|
|
const INTERNAL_ENUMS = ["TasksOpLedgerStatus"];
|
|
|
|
/** DB 枚举类型名 → contracts 枚举 */
|
|
const ENUMS: Record<string, z.ZodEnum> = {
|
|
AcquisitionPlanPurpose: C.PlanPurpose,
|
|
AcquisitionPlanStatus: C.PlanStatus,
|
|
AcquisitionPlanPriority: C.AcquisitionPriority,
|
|
AcquisitionSlotStatus: C.SlotStatus,
|
|
AgentRunTrigger: C.AgentTrigger,
|
|
AgentRunStatus: C.AgentRunStatus,
|
|
AgentRunEventType: C.AgentRunEventType,
|
|
ApprovalStatus: C.ApprovalStatus,
|
|
ArtifactKind: C.ArtifactKind,
|
|
AssetLocationLocationKind: C.LocationKind,
|
|
AssetLocationStatus: C.LocationStatus,
|
|
AssetOperationPhase: C.OperationPhase,
|
|
AssetOperationKind: C.OperationKind,
|
|
AssetOperationStatus: C.OperationStatus,
|
|
AssetUnitCoverageCoverage: C.CoverageKind,
|
|
AssetUnitCoverageSegment: C.Segment,
|
|
AutomationGrantStatus: C.GrantStatus,
|
|
ContentUnitKind: C.ContentUnitKind,
|
|
ContentUnitContentType: C.ContentType,
|
|
ContentUnitSegment: C.Segment,
|
|
ContentUnitStatus: C.ContentUnitStatus,
|
|
DecisionKind: C.DecisionKind,
|
|
DecisionStatus: C.DecisionStatus,
|
|
DecisionSeverity: C.Severity,
|
|
DownloadTaskOrigin: C.DownloadOrigin,
|
|
DownloadTaskStatus: C.DownloadStatus,
|
|
DownloadTaskPauseReason: C.PauseReason,
|
|
DownloaderInstanceKind: C.DownloaderKind,
|
|
DownloaderInstanceHealthStatus: C.InstanceHealth,
|
|
ExternalIdentityEntityType: C.ExternalEntityType,
|
|
ExternalIdentityStatus: C.IdentityStatus,
|
|
ExternalIdentityEvidenceLevel: C.EvidenceLevel,
|
|
ExternalIdentityVerifyStatus: C.IdentityVerifyStatus,
|
|
FeedSourcePurpose: C.FeedPurpose,
|
|
FeedSourceKind: C.FeedKind,
|
|
FeedSourceHealthStatus: C.FeedHealth,
|
|
IdentityRevisionAction: C.IdentityRevisionAction,
|
|
IdentityRevisionFromStatus: C.IdentityStatus,
|
|
IdentityRevisionToStatus: C.IdentityStatus,
|
|
IdentityRevisionActorType: C.ActorType,
|
|
LibraryRootMediaKind: C.LibraryMediaKind,
|
|
LibraryRootManagementMode: C.ManagementMode,
|
|
LibraryRootHealthStatus: C.InstanceHealth,
|
|
MediaAssetKind: C.AssetKind,
|
|
MediaAssetStatus: C.AssetStatus,
|
|
MediaEntityKind: C.MediaKind,
|
|
MediaEntityStatus: C.MediaEntityStatus,
|
|
MediaServerInstanceKind: C.MediaServerKind,
|
|
MediaServerInstanceHealthStatus: C.InstanceHealth,
|
|
ObservationStatus: C.ObservationStatus,
|
|
OrganizationFileResultRole: C.FileRole,
|
|
OrganizationFileResultMode: C.OrganizationAction,
|
|
OrganizationFileResultStatus: C.FileResultStatus,
|
|
OrganizationRecordTrigger: C.OrganizationTrigger,
|
|
OrganizationRecordActorType: C.ActorType,
|
|
OrganizationRecordSourcePathView: C.SourcePathView,
|
|
OrganizationRecordAction: C.OrganizationAction,
|
|
OrganizationRecordStatus: C.OrganizationStatus,
|
|
OutboxEventStatus: C.OutboxStatus,
|
|
PathBindingBindingKind: C.BindingKind,
|
|
PathBindingStatus: C.BindingStatus,
|
|
PathMappingViewKind: C.PathViewKind,
|
|
PathMappingVerifyStatus: C.PathMappingVerifyStatus,
|
|
PolicyRevisionKind: C.PolicyKind,
|
|
PolicyRevisionStatus: C.PolicyStatus,
|
|
PreferenceTagKind: C.PreferenceKind,
|
|
PreferenceTagOrigin: C.PreferenceOrigin,
|
|
PreferenceTagStatus: C.PreferenceStatus,
|
|
ReconcileRunKind: C.ReconcileKind,
|
|
ReconcileRunMode: C.ReconcileMode,
|
|
ReconcileRunStatus: C.ReconcileStatus,
|
|
SecretPurpose: C.SecretPurpose,
|
|
ServerMappingItemType: C.ServerItemType,
|
|
ServerMappingMatchStatus: C.ServerMatchStatus,
|
|
SiteAdapter: C.SiteAdapter,
|
|
SiteHealthStatus: C.SiteHealth,
|
|
SiteCircuitState: C.CircuitState,
|
|
SiteAccountAuthKind: C.SiteAuthKind,
|
|
SiteAccountStatus: C.SiteAccountStatus,
|
|
SourceReleaseResolutionStatus: C.ResolutionStatus,
|
|
SourceReleaseAvailability: C.ReleaseAvailability,
|
|
SubscriptionMediaKind: C.SubscriptionMediaKind,
|
|
SubscriptionMode: C.SubscriptionMode,
|
|
SubscriptionSeasonScope: C.SeasonScope,
|
|
SubscriptionOrigin: C.SubscriptionOrigin,
|
|
SubscriptionPriority: C.AcquisitionPriority,
|
|
SubscriptionStatus: C.SubscriptionStatus,
|
|
TaskStatus: C.TaskStatus,
|
|
TitleAliasKind: C.AliasKind,
|
|
TitleAliasSource: C.AliasSource,
|
|
};
|
|
|
|
describe("contracts ⇔ db 表结构同步", () => {
|
|
const tables = tableColumns();
|
|
|
|
it("每张表都有 Row schema", () => {
|
|
expect([...tables.keys()].filter((t) => !INTERNAL_TABLES.includes(t)).sort()).toEqual(Object.keys(ROWS).sort());
|
|
});
|
|
|
|
for (const [table, spec] of Object.entries(ROWS)) {
|
|
it(`${table} 列与 Row 字段一致`, () => {
|
|
const dbCols = (tables.get(table) ?? []).map(camel).filter((c) => !spec.dbOnly?.includes(c));
|
|
const apiCols = Object.keys(spec.row.shape).filter((c) => !spec.apiOnly?.includes(c));
|
|
expect(apiCols.sort()).toEqual(dbCols.sort());
|
|
});
|
|
}
|
|
|
|
it("每个 DB 枚举都有对应 contracts 枚举且取值一致", () => {
|
|
const aliases = enumAliases();
|
|
for (const internal of INTERNAL_ENUMS) aliases.delete(internal);
|
|
expect([...aliases.keys()].sort()).toEqual(Object.keys(ENUMS).sort());
|
|
for (const [name, values] of aliases) {
|
|
expect(ENUMS[name]!.options, name).toEqual(values);
|
|
}
|
|
});
|
|
});
|