168 lines
8.3 KiB
TypeScript
168 lines
8.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { createApp } from "../../apps/api/src/app.js";
|
|
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
|
|
const roots: string[] = [];
|
|
const services: RegistrationService[] = [];
|
|
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
|
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
|
|
|
function writeEvidence(caseId: string, file: string, value: unknown) {
|
|
const root = process.env.DADA_EVIDENCE_DIR_MODELS;
|
|
if (!root) return;
|
|
const directory = resolve(root, caseId);
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function editableModels(models: ReturnType<ModelConfigurationService["read"]>["models"]) {
|
|
return models.map(({ config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...candidate }) => candidate);
|
|
}
|
|
|
|
function createHarness() {
|
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-01-api-"));
|
|
roots.push(root);
|
|
const registration = new RegistrationService({
|
|
challengePepper: Buffer.alloc(32, 0xa1), clock: () => now,
|
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
|
invitePepper: Buffer.alloc(32, 0xa2), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0xa3),
|
|
});
|
|
services.push(registration);
|
|
const adminId = randomUUID();
|
|
registration.database.prepare(`
|
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
|
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
|
`).run(adminId, `wp3-admin-${adminId}@example.invalid`, randomUUID(), now);
|
|
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
|
const session = registration.issueAuthenticatedSession(adminId, "admin");
|
|
const csrf = registration.issueAdminCsrfToken(session.sessionToken);
|
|
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
|
return { csrf, models, registration, session };
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const service of services.splice(0)) service.close();
|
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
|
});
|
|
|
|
describe("TASK-WP3-01 model configuration API", () => {
|
|
it("reads the fixed seed and atomically replaces the default", async () => {
|
|
const { csrf, models, registration, session } = createHarness();
|
|
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
|
const seed = await app.inject({ headers, method: "GET", url: "/api/v1/models" });
|
|
expect(seed.statusCode).toBe(200);
|
|
expect(seed.json()).toMatchObject({
|
|
config_set_version: 1,
|
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
|
recommended_model_id: null,
|
|
});
|
|
|
|
const candidate = structuredClone(models.read().models);
|
|
candidate[0].enabled = false;
|
|
candidate[0].is_default = false;
|
|
candidate[1].is_default = true;
|
|
const response = await app.inject({
|
|
headers: {
|
|
...headers,
|
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
|
"idempotency-key": "wp3-01-api-replace-default-000000000001",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
method: "PUT",
|
|
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
|
url: "/api/v1/admin/models/configuration",
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toMatchObject({ config_set_version: 2, configured_default_model_id: "gemini-3-pro-image-preview" });
|
|
expect(response.json().models.filter((model: { enabled: boolean; is_default: boolean }) => model.enabled && model.is_default)).toHaveLength(1);
|
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "request.json", { expected_config_set_version: 1, models: candidate });
|
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "response.json", response.json());
|
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "db-diff.json", {
|
|
current_version: models.read().config_set_version,
|
|
audit_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'model_configuration_replace'").get() as { count: number }).count,
|
|
outbox_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE topic = 'model_config_changed'").get() as { count: number }).count,
|
|
});
|
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "transaction-trace.json", { begin_mode: "IMMEDIATE", pointer_switch: "last_statement", visible_default_count: 1 });
|
|
await app.close();
|
|
});
|
|
|
|
it("returns frozen CAS and priority errors without changing the current set", async () => {
|
|
const { csrf, models, registration, session } = createHarness();
|
|
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
|
const candidate = structuredClone(models.read().models);
|
|
candidate[1].recommendation_priority = candidate[0].recommendation_priority;
|
|
const invalid = await app.inject({
|
|
headers: {
|
|
...headers,
|
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
|
"idempotency-key": "wp3-01-api-invalid-priority-000000000001",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
method: "PUT",
|
|
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
|
url: "/api/v1/admin/models/configuration",
|
|
});
|
|
expect(invalid.statusCode).toBe(409);
|
|
expect(invalid.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" } });
|
|
|
|
const fractionalModels = editableModels(models.read().models);
|
|
fractionalModels[1].recommendation_priority = 1.5;
|
|
const fractional = await app.inject({
|
|
headers: {
|
|
...headers,
|
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
|
"idempotency-key": "wp3-01-api-fractional-priority-000000001",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
method: "PUT",
|
|
payload: { expected_config_set_version: 1, models: fractionalModels },
|
|
url: "/api/v1/admin/models/configuration",
|
|
});
|
|
expect(fractional.statusCode).toBe(400);
|
|
expect(fractional.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
|
|
|
const missingModels = editableModels(models.read().models) as Array<Record<string, unknown>>;
|
|
delete missingModels[1].recommendation_priority;
|
|
const missing = await app.inject({
|
|
headers: {
|
|
...headers,
|
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
|
"idempotency-key": "wp3-01-api-missing-priority-00000000001",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
method: "PUT",
|
|
payload: { expected_config_set_version: 1, models: missingModels },
|
|
url: "/api/v1/admin/models/configuration",
|
|
});
|
|
expect(missing.statusCode).toBe(400);
|
|
expect(missing.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
|
|
|
const advanced = structuredClone(models.read().models);
|
|
advanced[1].display_name = "CAS 已提交";
|
|
models.replace({ actorId: randomUUID(), expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-api-cas-advance-000000000000001", models: advanced });
|
|
const stale = await app.inject({
|
|
headers: {
|
|
...headers,
|
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
|
"idempotency-key": "wp3-01-api-cas-stale-00000000000000000001",
|
|
"x-csrf-token": csrf,
|
|
},
|
|
method: "PUT",
|
|
payload: { expected_config_set_version: 1, models: editableModels(models.read().models) },
|
|
url: "/api/v1/admin/models/configuration",
|
|
});
|
|
expect(stale.statusCode).toBe(412);
|
|
expect(stale.json()).toMatchObject({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", details: { latest_version: 2 } } });
|
|
expect(models.read().config_set_version).toBe(2);
|
|
await app.close();
|
|
});
|
|
});
|