683 lines
57 KiB
TypeScript
683 lines
57 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createHmac } from "node:crypto";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { GenerationTask } from "@miragenflow/contracts";
|
|
import { createHttpServer } from "../src/app/http.ts";
|
|
import { createTotpSecret, encryptSecret, hashChallenge } from "../src/shared/auth.ts";
|
|
import { createStore, reserveBalance } from "../src/store.ts";
|
|
|
|
const accessTokenSecret = "v1-regression-test-secret";
|
|
|
|
function config(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
accessTokenSecret,
|
|
accessTokenTtlSeconds: 900,
|
|
refreshCookieName: "refresh",
|
|
adminEmail: "admin@example.com",
|
|
adminPassword: "change-me-now",
|
|
adminMfaRequired: false,
|
|
userMfaRequired: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function totpCode(secret: string, now = Date.now()) {
|
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
let buffer = 0;
|
|
let bits = 0;
|
|
const bytes: number[] = [];
|
|
for (const character of secret) {
|
|
const value = alphabet.indexOf(character);
|
|
buffer = (buffer << 5) | value;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
bytes.push((buffer >>> (bits - 8)) & 255);
|
|
bits -= 8;
|
|
}
|
|
}
|
|
const message = Buffer.alloc(8);
|
|
message.writeBigInt64BE(BigInt(Math.floor(now / 30_000)));
|
|
const digest = createHmac("sha1", Buffer.from(bytes)).update(message).digest();
|
|
const offset = digest[digest.length - 1] & 15;
|
|
const value = ((digest[offset] & 127) << 24) | (digest[offset + 1] << 16) | (digest[offset + 2] << 8) | digest[offset + 3];
|
|
return String(value % 1_000_000).padStart(6, "0");
|
|
}
|
|
|
|
async function start(overrides: Record<string, unknown> = {}) {
|
|
const store = createStore();
|
|
const server = createHttpServer(store, config(overrides));
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
assert.ok(address && typeof address !== "string");
|
|
return { store, server, base: `http://127.0.0.1:${address.port}` };
|
|
}
|
|
|
|
async function registerAndLogin(base: string, email: string, password = "password123", deviceId?: string) {
|
|
const registration = await fetch(`${base}/api/v1/auth/register`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, deviceId }) });
|
|
const registered = await registration.json() as { data: { userId: string; devVerificationCode: string } };
|
|
assert.equal(registration.status, 201);
|
|
const verification = await fetch(`${base}/api/v1/auth/verify-email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ userId: registered.data.userId, code: registered.data.devVerificationCode }) });
|
|
assert.equal(verification.status, 200);
|
|
const login = await fetch(`${base}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, deviceId }) });
|
|
return { registered: registered.data, login, payload: await login.json() as { data: { accessToken?: string; mfaRequired?: boolean; challengeId?: string; challengeType?: "verify" | "setup"; mfaEnrollment?: { secret: string; recoveryCodes: string[] } } } };
|
|
}
|
|
|
|
async function loginAdmin(base: string, email: string, password: string, secret: string, recoveryCode?: string) {
|
|
const first = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password }) });
|
|
const firstPayload = await first.json() as { data: { accessToken?: string; challengeId?: string; mfaRequired?: boolean } };
|
|
assert.equal(firstPayload.data.mfaRequired, true);
|
|
const second = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, challengeId: firstPayload.data.challengeId, ...(recoveryCode ? { recoveryCode } : { mfaCode: totpCode(secret) }) }) });
|
|
const secondPayload = await second.json() as { data: { accessToken: string } };
|
|
assert.equal(second.status, 200);
|
|
return secondPayload.data.accessToken;
|
|
}
|
|
|
|
test("user MFA recovery endpoint consumes a recovery code once", async () => {
|
|
const { server, base } = await start({ userMfaRequired: true });
|
|
try {
|
|
const first = await registerAndLogin(base, "recovery-endpoint@example.com");
|
|
assert.equal(first.payload.data.challengeType, "setup");
|
|
const recoveryCode = first.payload.data.mfaEnrollment?.recoveryCodes[0];
|
|
assert.ok(recoveryCode);
|
|
assert.equal(first.payload.data.mfaRequired, true);
|
|
const confirmed = await fetch(`${base}/api/v1/auth/mfa/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId: first.payload.data.challengeId, code: totpCode(first.payload.data.mfaEnrollment!.secret) }) });
|
|
assert.equal(confirmed.status, 200);
|
|
|
|
const secondLogin = await fetch(`${base}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "recovery-endpoint@example.com", password: "password123" }) });
|
|
const second = { payload: await secondLogin.json() as { data: { accessToken?: string; mfaRequired?: boolean; challengeId?: string } } };
|
|
const recovered = await fetch(`${base}/api/v1/auth/mfa/recovery`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId: second.payload.data.challengeId, recoveryCode }) });
|
|
assert.equal(recovered.status, 200);
|
|
assert.ok((await recovered.json() as { data: { accessToken?: string } }).data.accessToken);
|
|
const thirdLogin = await fetch(`${base}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "recovery-endpoint@example.com", password: "password123" }) });
|
|
const third = { payload: await thirdLogin.json() as { data: { challengeId?: string } } };
|
|
const repeated = await fetch(`${base}/api/v1/auth/mfa/recovery`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId: third.payload.data.challengeId, recoveryCode }) });
|
|
assert.equal(repeated.status, 401);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("sessions, plan entitlements, and remaining plan balance are user-scoped", async () => {
|
|
const { server, base } = await start();
|
|
try {
|
|
const first = await registerAndLogin(base, "sessions-plans@example.com", "password123", "browser-a");
|
|
const second = await fetch(`${base}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "sessions-plans@example.com", password: "password123", deviceId: "browser-b" }) });
|
|
const secondPayload = await second.json() as { data: { accessToken: string } };
|
|
const auth = (token: string) => ({ authorization: `Bearer ${token}`, "content-type": "application/json" });
|
|
const sessions = await fetch(`${base}/api/v1/me/sessions`, { headers: auth(first.payload.data.accessToken!) });
|
|
const sessionItems = (await sessions.json() as { data: { items: Array<{ id: string; deviceId?: string }> } }).data.items;
|
|
assert.equal(sessionItems.length, 2);
|
|
const browserA = sessionItems.find((item) => item.deviceId === "browser-a");
|
|
assert.ok(browserA);
|
|
const revoked = await fetch(`${base}/api/v1/me/sessions/${browserA.id}`, { method: "DELETE", headers: auth(first.payload.data.accessToken!) });
|
|
assert.equal(revoked.status, 200);
|
|
assert.equal((await fetch(`${base}/api/v1/me`, { headers: auth(first.payload.data.accessToken!) })).status, 401);
|
|
const remaining = await fetch(`${base}/api/v1/me/sessions`, { headers: auth(secondPayload.data.accessToken) });
|
|
assert.equal((await remaining.json() as { data: { items: unknown[] } }).data.items.length, 1);
|
|
|
|
const purchase = await fetch(`${base}/api/v1/plans/starter/purchase`, { method: "POST", headers: { ...auth(secondPayload.data.accessToken), "idempotency-key": "plan-regression" }, body: "{}" });
|
|
assert.equal(purchase.status, 202);
|
|
const plans = await fetch(`${base}/api/v1/me/plans`, { headers: auth(secondPayload.data.accessToken) });
|
|
const planPayload = await plans.json() as { data: { items: Array<{ plan?: { id: string }; remainingBalance: number }> } };
|
|
assert.equal(plans.status, 200);
|
|
assert.equal(planPayload.data.items[0].plan?.id, "starter");
|
|
assert.equal(planPayload.data.items[0].remainingBalance, 100);
|
|
const task = await fetch(`${base}/api/v1/tasks/image`, { method: "POST", headers: { ...auth(secondPayload.data.accessToken), "idempotency-key": "plan-bucket-task" }, body: JSON.stringify({ modelProductId: "basic-image-v1", prompt: "plan bucket" }) });
|
|
assert.equal(task.status, 202);
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
const afterTask = await fetch(`${base}/api/v1/me/plans`, { headers: auth(secondPayload.data.accessToken) });
|
|
assert.equal((await afterTask.json() as { data: { items: Array<{ remainingBalance: number }> } }).data.items[0].remainingBalance, 90);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin image model products publish capability parameters and require a resolution matrix", async () => {
|
|
const { server, base } = await start();
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
|
const created = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-create" }, body: JSON.stringify({ id: "openai-image-product", publicModelId: "gpt-image-2", name: "OpenAI 图片模型", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1", "16:9"] }] }) });
|
|
assert.equal(created.status, 201);
|
|
const createdPayload = await created.json() as { data: { item: { capabilitiesSchema?: Array<{ capability: string; parameters?: Record<string, { enum?: string[] }> }> } } };
|
|
const imageSchema = createdPayload.data.item.capabilitiesSchema?.find((item) => item.capability === "image");
|
|
assert.ok(imageSchema);
|
|
assert.deepEqual(imageSchema.parameters?.ratio.enum, ["auto", "1:1", "16:9", "9:16", "3:4", "4:5", "3:2", "2:3", "4:3", "1.91:1", "2.35:1", "21:9"]);
|
|
assert.deepEqual(imageSchema.parameters?.resolution.enum, ["1K", "2K", "4K"]);
|
|
assert.deepEqual(imageSchema.parameters?.input_fidelity.enum, ["low", "high"]);
|
|
|
|
const invalid = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-invalid" }, body: JSON.stringify({ id: "openai-image-invalid", publicModelId: "gpt-image-invalid", name: "无分辨率图片模型", capabilities: ["image"], enabled: true, resolutions: [] }) });
|
|
assert.equal(invalid.status, 422);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin channel groups keep model routes separate from public model records", async () => {
|
|
const { server, base, store } = await start();
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
|
const firstProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-first" }, body: JSON.stringify({ id: "group-basic-image", publicModelId: "group-basic-image-v1", name: "分组基础图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
|
|
assert.equal(firstProduct.status, 201);
|
|
const secondProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-second" }, body: JSON.stringify({ id: "group-advanced-image", publicModelId: "group-advanced-image-v1", name: "分组高级图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "2K", label: "2K", width: 2048, height: 2048, ratios: ["1:1"] }] }) });
|
|
assert.equal(secondProduct.status, 201);
|
|
const channel = store.channels.find((item) => item.id === "channel-a")!;
|
|
const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-product-models" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "group-basic-image-v1", source: "manual", enabled: true }, { requestModelId: "group-advanced-image-v1", source: "manual", enabled: true }] }) });
|
|
assert.equal(channelUpdate.status, 200);
|
|
const created = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], productIds: ["group-basic-image"], channelIds: ["channel-a"], retryBudget: 2 }) });
|
|
assert.equal(created.status, 422);
|
|
const createdWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind-routes" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], routes: [{ publicModelId: "group-basic-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }] }) });
|
|
assert.equal(createdWithRoutes.status, 201);
|
|
const createdPayload = await createdWithRoutes.json() as { data: { item: { routes: Array<{ publicModelId: string }> } } };
|
|
assert.deepEqual(createdPayload.data.item.routes.map((route) => route.publicModelId), ["group-basic-image-v1"]);
|
|
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
|
|
|
|
const listed = await fetch(`${base}/api/v1/admin/channel-groups`, { headers });
|
|
assert.equal(listed.status, 200);
|
|
const listedPayload = await listed.json() as { data: { items: Array<{ id: string; routes?: Array<{ publicModelId: string }>; products?: Array<{ productId: string }> }> } };
|
|
const listedGroup = listedPayload.data.items.find((item) => item.id === "image-product-group");
|
|
assert.deepEqual(listedGroup?.routes?.map((route) => route.publicModelId), ["group-basic-image-v1"]);
|
|
assert.deepEqual(listedGroup?.products?.map((product) => product.productId), ["group-basic-image"]);
|
|
|
|
const edited = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind" }, body: JSON.stringify({ productIds: ["group-advanced-image"], capabilities: ["image"], channelIds: ["channel-a"], retryBudget: 2, enabled: true }) });
|
|
assert.equal(edited.status, 422);
|
|
const editedWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind-routes" }, body: JSON.stringify({ routes: [{ publicModelId: "group-advanced-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }], capabilities: ["image"], enabled: true }) });
|
|
assert.equal(editedWithRoutes.status, 200);
|
|
assert.deepEqual(store.channelGroups.find((group) => group.id === "image-product-group")?.routes.map((route) => route.publicModelId), ["group-advanced-image-v1"]);
|
|
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin channel groups reject incompatible routes and legacy model binding fields", async () => {
|
|
const { server, base, store } = await start();
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
|
const channel = store.channels.find((item) => item.id === "channel-a")!;
|
|
const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-capability-model" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "capability-image-v1", source: "manual", enabled: true }] }) });
|
|
assert.equal(channelUpdate.status, 200);
|
|
const product = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-product" }, body: JSON.stringify({ id: "capability-image-product", publicModelId: "capability-image-v1", name: "能力校验图片产品", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
|
|
assert.equal(product.status, 201);
|
|
const imageGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-image" }, body: JSON.stringify({ id: "capability-image-group", name: "图片能力组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
|
|
assert.equal(imageGroup.status, 201);
|
|
const textGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-text" }, body: JSON.stringify({ id: "capability-text-group", name: "文字能力组", capabilities: ["text"], routes: [] }) });
|
|
assert.equal(textGroup.status, 201);
|
|
|
|
const incompatibleMode = await fetch(`${base}/api/v1/admin/channel-groups/capability-image-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-change" }, body: JSON.stringify({ capabilities: ["text"] }) });
|
|
assert.equal(incompatibleMode.status, 422);
|
|
assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.capabilities, ["image"]);
|
|
assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.routes.map((route) => route.publicModelId), ["capability-image-v1"]);
|
|
|
|
const incompatibleProduct = await fetch(`${base}/api/v1/admin/model-products/capability-image-product`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-product-change" }, body: JSON.stringify({ channelGroupId: "capability-text-group" }) });
|
|
assert.equal(incompatibleProduct.status, 422);
|
|
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.enabled, false);
|
|
|
|
const duplicateBinding = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-ownership-conflict" }, body: JSON.stringify({ id: "capability-other-group", name: "重复绑定组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
|
|
assert.equal(duplicateBinding.status, 409);
|
|
assert.equal(store.channelGroups.some((group) => group.id === "capability-other-group"), false);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("message provider and template writes survive a file-store restart", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "miragenflow-message-persistence-"));
|
|
const file = join(directory, "store.json");
|
|
const store = createStore(file);
|
|
const server = createHttpServer(store, config());
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
assert.ok(address && typeof address !== "string");
|
|
const base = `http://127.0.0.1:${address.port}`;
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
|
const provider = await fetch(`${base}/api/v1/admin/message-providers`, { method: "POST", headers: { ...headers, "idempotency-key": "persistent-message-provider-create" }, body: JSON.stringify({ id: "persistent-message-provider", name: "持久化消息适配器", channel: "email" }) });
|
|
assert.equal(provider.status, 201);
|
|
const template = await fetch(`${base}/api/v1/admin/message-templates`, { method: "POST", headers: { ...headers, "idempotency-key": "persistent-message-template-create" }, body: JSON.stringify({ purpose: "register", channel: "email", content: "验证码:{{code}}" }) });
|
|
assert.equal(template.status, 201);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
const restored = createStore(file);
|
|
try {
|
|
assert.equal(restored.messageProviders.has("persistent-message-provider"), true);
|
|
assert.equal([...restored.messageTemplates.values()].some((item) => item.content === "验证码:{{code}}"), true);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("admin unknown-task reconciliation rejects overcharge and stores real image metadata", async () => {
|
|
const { store, server, base } = await start();
|
|
const taskId = "reconcile-image-task";
|
|
const ownerId = "reconcile-user";
|
|
const now = new Date().toISOString();
|
|
assert.equal(reserveBalance(store, ownerId, 10, taskId), false);
|
|
store.balances.set(ownerId, { available: 100, reserved: 0 });
|
|
assert.equal(reserveBalance(store, ownerId, 10, taskId), true);
|
|
store.tasks.set(taskId, {
|
|
id: taskId,
|
|
ownerId,
|
|
taskType: "image",
|
|
modelProductId: "basic-image",
|
|
status: "unknown",
|
|
estimatedCost: 10,
|
|
reservedCost: 10,
|
|
count: 1,
|
|
pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
|
|
modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
|
|
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
|
|
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
eventSequence: 0,
|
|
attempts: [{ id: "reconcile-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
|
outputs: [],
|
|
inputSnapshot: { references: [], params: { resolution: "2K" } },
|
|
} satisfies GenerationTask);
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const invalidForm = new FormData(); invalidForm.set("outcome", "success"); invalidForm.append("file", new Blob([Buffer.from("not-an-image")], { type: "image/png" }), "invalid.png");
|
|
const invalid = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-invalid", "if-match": "0" }, body: invalidForm });
|
|
assert.equal(invalid.status, 415);
|
|
assert.equal(store.tasks.get(taskId)?.status, "unknown");
|
|
assert.equal(store.balances.get(ownerId)?.reserved, 10);
|
|
const form = new FormData(); form.set("outcome", "success"); form.set("chargedAmount", "99"); form.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
|
|
const overcharged = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-overcharge", "if-match": "0" }, body: form });
|
|
assert.equal(overcharged.status, 422);
|
|
assert.equal(store.tasks.get(taskId)?.status, "unknown");
|
|
form.set("chargedAmount", "10");
|
|
const response = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: form });
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json() as { data: { item: GenerationTask } };
|
|
const settledEntries = store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length;
|
|
const repeatedForm = new FormData(); repeatedForm.set("outcome", "success"); repeatedForm.set("chargedAmount", "10"); repeatedForm.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
|
|
const repeated = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: repeatedForm });
|
|
assert.equal(repeated.status, 200);
|
|
assert.equal(store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length, settledEntries);
|
|
const conflicting = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json", "idempotency-key": "reconcile-image-success" }, body: JSON.stringify({ outcome: "failure" }) });
|
|
assert.equal(conflicting.status, 409);
|
|
const output = payload.data.item.outputs[0];
|
|
assert.equal(payload.data.item.status, "succeeded");
|
|
assert.equal(payload.data.item.reservedCost, 0);
|
|
assert.equal(output.metadata?.width, 1);
|
|
assert.equal(output.metadata?.height, 1);
|
|
assert.equal(output.metadata?.size, "1x1");
|
|
assert.equal(output.metadata?.format, "png");
|
|
assert.equal(output.metadata?.source, "base64");
|
|
assert.equal(output.metadata?.resolution, "2K");
|
|
assert.equal(output.unitPriceSnapshot, 10);
|
|
assert.equal(output.chargedAmount, 10);
|
|
assert.equal(store.balances.get(ownerId)?.reserved, 0);
|
|
assert.equal(store.ledger.some((entry) => entry.type === "settle" && entry.referenceId === taskId), true);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin unknown-task failure reconciliation releases the full reservation", async () => {
|
|
const { store, server, base } = await start();
|
|
const taskId = "reconcile-failure-task";
|
|
const ownerId = "reconcile-failure-user";
|
|
const now = new Date().toISOString();
|
|
store.balances.set(ownerId, { available: 20, reserved: 0 });
|
|
assert.equal(reserveBalance(store, ownerId, 10, taskId), true);
|
|
store.tasks.set(taskId, {
|
|
id: taskId,
|
|
ownerId,
|
|
taskType: "image",
|
|
modelProductId: "basic-image",
|
|
status: "unknown",
|
|
estimatedCost: 10,
|
|
reservedCost: 10,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
eventSequence: 0,
|
|
pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
|
|
modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
|
|
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
|
|
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
|
|
attempts: [{ id: "reconcile-failure-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
|
outputs: [],
|
|
} satisfies GenerationTask);
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const response = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json", "idempotency-key": "reconcile-failure", "if-match": "0" }, body: JSON.stringify({ outcome: "failure" }) });
|
|
assert.equal(response.status, 200);
|
|
assert.equal(store.tasks.get(taskId)?.status, "failed");
|
|
assert.equal(store.tasks.get(taskId)?.reservedCost, 0);
|
|
assert.deepEqual({ available: store.balances.get(ownerId)?.available, reserved: store.balances.get(ownerId)?.reserved }, { available: 20, reserved: 0 });
|
|
assert.equal(store.tasks.get(taskId)?.attempts[0]?.reconciliationStatus, "confirmed_failure");
|
|
assert.equal(store.ledger.some((entry) => entry.type === "release" && entry.referenceId === taskId), true);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("finance administrators can list balance targets and require separate MFA approval", async () => {
|
|
const firstSecret = createTotpSecret();
|
|
const secondSecret = createTotpSecret();
|
|
const securitySecret = createTotpSecret();
|
|
const recoveryCode = "ADMINRECOVERY";
|
|
const { store, server, base } = await start({
|
|
adminMfaRequired: true,
|
|
adminAccounts: [
|
|
{ id: "admin:first", email: "first-admin@example.com", password: "first-admin-password", role: "finance", mfaSecret: firstSecret, mfaRequired: true },
|
|
{ id: "admin:second", email: "second-admin@example.com", password: "second-admin-password", role: "finance", mfaSecret: secondSecret, mfaRequired: true, recoveryCodeHashes: [hashChallenge(recoveryCode, accessTokenSecret)] },
|
|
{ id: "admin:security", email: "security-admin@example.com", password: "security-admin-password", role: "support", mfaSecret: securitySecret, mfaRequired: true },
|
|
],
|
|
});
|
|
try {
|
|
const user = await registerAndLogin(base, "approval-user@example.com");
|
|
const requester = await loginAdmin(base, "first-admin@example.com", "first-admin-password", firstSecret);
|
|
const approver = await loginAdmin(base, "second-admin@example.com", "second-admin-password", secondSecret, recoveryCode);
|
|
const securityOnly = await loginAdmin(base, "security-admin@example.com", "security-admin-password", securitySecret);
|
|
const targets = await fetch(`${base}/api/v1/admin/billing/users`, { headers: { authorization: `Bearer ${requester}` } });
|
|
assert.equal(targets.status, 200);
|
|
const target = ((await targets.json()) as { data: { items: Array<Record<string, unknown>> } }).data.items.find((item) => item.id === user.registered.userId);
|
|
assert.ok(target);
|
|
const allowedTargetFields = ["balance", "displayName", "email", "id", "phone", "status", "username"];
|
|
assert.deepEqual(Object.keys(target).filter((key) => !allowedTargetFields.includes(key)), []);
|
|
assert.ok("balance" in target);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/users`, { headers: { authorization: `Bearer ${requester}` } })).status, 403);
|
|
const forbidden = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}/balance-adjustments`, { method: "POST", headers: { authorization: `Bearer ${securityOnly}`, "content-type": "application/json", "idempotency-key": "security-balance-adjustment" }, body: JSON.stringify({ amount: 1, reason: "不应允许安全管理员调账" }) });
|
|
assert.equal(forbidden.status, 403);
|
|
const request = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}/balance-adjustments`, { method: "POST", headers: { authorization: `Bearer ${requester}`, "content-type": "application/json", "idempotency-key": "balance-adjustment-request" }, body: JSON.stringify({ amount: 25, reason: "人工账本回归测试" }) });
|
|
assert.equal(request.status, 202);
|
|
assert.equal((await fetch(`${base}/api/v1/balance`, { headers: { authorization: `Bearer ${user.payload.data.accessToken!}` } })).status, 200);
|
|
const before = await fetch(`${base}/api/v1/balance`, { headers: { authorization: `Bearer ${user.payload.data.accessToken!}` } });
|
|
assert.equal((await before.json() as { data: { available: number } }).data.available, 0);
|
|
const approval = ((await request.json()) as { data: { item: { id: string } } }).data.item;
|
|
const execute = await fetch(`${base}/api/v1/admin/approvals/${approval.id}/approve`, { method: "POST", headers: { authorization: `Bearer ${approver}`, "content-type": "application/json", "idempotency-key": "balance-adjustment-approve", "if-match": "1" }, body: JSON.stringify({ mfaCode: totpCode(secondSecret) }) });
|
|
assert.equal(execute.status, 200);
|
|
assert.ok(store.adminAccounts.get("admin:second")?.mfaLastTotpCounter !== undefined);
|
|
const after = await fetch(`${base}/api/v1/balance`, { headers: { authorization: `Bearer ${user.payload.data.accessToken!}` } });
|
|
assert.equal((await after.json() as { data: { available: number } }).data.available, 25);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("local super administrator can approve its own balance increase and deduction", async () => {
|
|
const { store, server, base } = await start({ adminSelfApprovalAllowed: true });
|
|
try {
|
|
const user = await registerAndLogin(base, "self-approval-balance@example.com");
|
|
store.balances.set(user.registered.userId, { available: 10, reserved: 0 });
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const adminHeaders = (idempotencyKey: string) => ({ authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json", "idempotency-key": idempotencyKey });
|
|
|
|
const adjust = async (amount: number, key: string) => {
|
|
const request = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}/balance-adjustments`, { method: "POST", headers: adminHeaders(`${key}-request`), body: JSON.stringify({ amount, reason: amount > 0 ? "本地增加金币" : "本地扣减金币" }) });
|
|
assert.equal(request.status, 202);
|
|
const approval = ((await request.json()) as { data: { item: { id: string; version: number } } }).data.item;
|
|
const execute = await fetch(`${base}/api/v1/admin/approvals/${approval.id}/approve`, { method: "POST", headers: { ...adminHeaders(`${key}-approve`), "if-match": String(approval.version) }, body: "{}" });
|
|
assert.equal(execute.status, 200);
|
|
};
|
|
|
|
await adjust(40, "self-balance-add");
|
|
assert.equal((await (await fetch(`${base}/api/v1/balance`, { headers: { authorization: `Bearer ${user.payload.data.accessToken!}` } })).json() as { data: { available: number } }).data.available, 50);
|
|
assert.equal((store.buckets.get(user.registered.userId) || []).reduce((total, bucket) => total + bucket.remaining, 0), 50);
|
|
await adjust(10, "self-balance-add-second-bucket");
|
|
assert.equal((store.buckets.get(user.registered.userId) || []).reduce((total, bucket) => total + bucket.remaining, 0), 60);
|
|
await adjust(-55, "self-balance-deduct-across-buckets");
|
|
assert.equal((await (await fetch(`${base}/api/v1/balance`, { headers: { authorization: `Bearer ${user.payload.data.accessToken!}` } })).json() as { data: { available: number } }).data.available, 5);
|
|
assert.deepEqual((store.buckets.get(user.registered.userId) || []).map((bucket) => bucket.remaining), [0, 0, 5]);
|
|
assert.ok(store.ledger.filter((entry) => entry.userId === user.registered.userId && entry.type === "adjustment").every((entry) => Boolean(entry.bucketId)));
|
|
assert.equal([...store.adminIdempotency.values()].every((record) => record.state === "completed"), true);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("administrator initial plan grant creates matching balance bucket ledger and entitlement", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const plan = store.plans.find((item) => item.includedBalance > 0);
|
|
assert.ok(plan);
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const accessToken = (await login.json() as { data: { accessToken: string } }).data.accessToken;
|
|
const headers = { authorization: `Bearer ${accessToken}`, "content-type": "application/json", "idempotency-key": "initial-plan-user" };
|
|
const body = JSON.stringify({ username: "initial-plan-user", email: "initial-plan@example.com", password: "password123", planId: plan.id, welcomeMessage: "欢迎使用" });
|
|
const response = await fetch(`${base}/api/v1/admin/users`, { method: "POST", headers, body });
|
|
assert.equal(response.status, 201);
|
|
const firstPayload = await response.json() as { data: { item: { id: string }; initialPassword?: string } };
|
|
const userId = firstPayload.data.item.id;
|
|
assert.equal(firstPayload.data.initialPassword, "password123");
|
|
const entitlement = [...store.entitlements.values()].find((item) => item.userId === userId);
|
|
assert.ok(entitlement?.creditBucketId);
|
|
const bucket = (store.buckets.get(userId) || []).find((item) => item.id === entitlement.creditBucketId);
|
|
assert.equal(bucket?.remaining, plan.includedBalance);
|
|
assert.equal(store.balances.get(userId)?.available, plan.includedBalance);
|
|
assert.equal(store.ledger.find((entry) => entry.referenceId === entitlement.id)?.bucketId, bucket?.id);
|
|
assert.ok([...store.messageOutbox.values()].some((item) => item.idempotencyKey === "initial-plan-user:welcome"));
|
|
assert.ok(store.audit.some((item) => item.action === "user.create" && item.objectId === userId));
|
|
const counts = { users: store.users.size, buckets: store.buckets.get(userId)?.length, ledger: store.ledger.length, entitlements: store.entitlements.size, messages: store.messageOutbox.size, audit: store.audit.length };
|
|
const replay = await fetch(`${base}/api/v1/admin/users`, { method: "POST", headers, body });
|
|
assert.equal(replay.status, 201);
|
|
const replayPayload = await replay.json() as { data: { item: { id: string }; initialPassword?: string } };
|
|
assert.equal(replayPayload.data.item.id, userId);
|
|
assert.equal(replayPayload.data.initialPassword, undefined);
|
|
assert.deepEqual({ users: store.users.size, buckets: store.buckets.get(userId)?.length, ledger: store.ledger.length, entitlements: store.entitlements.size, messages: store.messageOutbox.size, audit: store.audit.length }, counts);
|
|
const idempotency = [...store.adminIdempotency.values()].find((item) => item.statusCode === 201);
|
|
assert.equal(idempotency?.state, "completed");
|
|
assert.equal(JSON.stringify(idempotency?.data).includes("password123"), false);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin user status mutations reject stale versions", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const user = await registerAndLogin(base, "user-version-conflict@example.com");
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const accessToken = (await login.json() as { data: { accessToken: string } }).data.accessToken;
|
|
const auth = { authorization: `Bearer ${accessToken}`, "content-type": "application/json" };
|
|
const detail = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}`, { headers: auth });
|
|
assert.equal(detail.status, 200);
|
|
const detailPayload = await detail.json() as { data: { item: { version: number; status: string } } };
|
|
assert.equal(detailPayload.data.item.version, 1);
|
|
const freeze = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}/freeze`, { method: "POST", headers: { ...auth, "if-match": String(detailPayload.data.item.version), "idempotency-key": "user-version-freeze" }, body: "{}" });
|
|
assert.equal(freeze.status, 200);
|
|
const freezePayload = await freeze.json() as { data: { version: number; status: string } };
|
|
assert.equal(freezePayload.data.status, "frozen");
|
|
assert.equal(freezePayload.data.version, 2);
|
|
const stale = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}`, { method: "PATCH", headers: { ...auth, "if-match": "1", "idempotency-key": "user-version-stale-update" }, body: JSON.stringify({ status: "active" }) });
|
|
assert.equal(stale.status, 409);
|
|
const stalePayload = await stale.json() as { error?: { code?: string } };
|
|
assert.equal(stalePayload.error?.code, "VERSION_CONFLICT");
|
|
assert.equal(store.users.get(user.registered.userId)?.status, "frozen");
|
|
assert.equal(store.users.get(user.registered.userId)?.version, 2);
|
|
const unfreeze = await fetch(`${base}/api/v1/admin/users/${user.registered.userId}/unfreeze`, { method: "POST", headers: { ...auth, "if-match": "2", "idempotency-key": "user-version-unfreeze" }, body: "{}" });
|
|
assert.equal(unfreeze.status, 200);
|
|
assert.equal((await unfreeze.json() as { data: { version: number; status: string } }).data.version, 3);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("assets enforce ownership and deduplicate repeated object saves", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const owner = await registerAndLogin(base, "asset-owner@example.com");
|
|
const other = await registerAndLogin(base, "asset-other@example.com");
|
|
const objectId = "object-owner-1";
|
|
store.objects.set(objectId, { id: objectId, ownerId: owner.registered.userId, mimeType: "image/png", data: Buffer.from("x").toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
const auth = { authorization: `Bearer ${owner.payload.data.accessToken!}`, "content-type": "application/json" };
|
|
const first = await fetch(`${base}/api/v1/assets`, { method: "POST", headers: auth, body: JSON.stringify({ objectId, type: "image", title: "测试资产" }) });
|
|
const firstPayload = await first.json() as { data: { id: string } };
|
|
assert.equal(first.status, 201);
|
|
const second = await fetch(`${base}/api/v1/assets`, { method: "POST", headers: auth, body: JSON.stringify({ objectId, type: "image", title: "重复保存" }) });
|
|
const secondPayload = await second.json() as { data: { id: string } };
|
|
assert.equal(second.status, 200);
|
|
assert.equal(secondPayload.data.id, firstPayload.data.id);
|
|
const denied = await fetch(`${base}/api/v1/assets`, { method: "POST", headers: { authorization: `Bearer ${other.payload.data.accessToken!}`, "content-type": "application/json" }, body: JSON.stringify({ objectId, type: "image" }) });
|
|
assert.equal(denied.status, 403);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("WebDAV manifest retention is capped and valid writes keep the local record", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const user = await registerAndLogin(base, "webdav-retention@example.com");
|
|
store.webdav.set(user.registered.userId, { userId: user.registered.userId, configured: true, directory: "miragenflow", state: "ready", retentionDays: 30, encryptedUrl: encryptSecret("https://127.0.0.1:1", accessTokenSecret), manifestRetentionExpiresAt: new Date().toISOString(), manifestExtensionDays: 0 });
|
|
const auth = { authorization: `Bearer ${user.payload.data.accessToken!}`, "content-type": "application/json" };
|
|
const extension = await fetch(`${base}/api/v1/me/webdav/manifest/retention`, { method: "POST", headers: auth, body: JSON.stringify({ days: 20 }) });
|
|
assert.equal(extension.status, 200);
|
|
assert.equal((await extension.json() as { data: { manifestExtensionDays: number } }).data.manifestExtensionDays, 20);
|
|
const overLimit = await fetch(`${base}/api/v1/me/webdav/manifest/retention`, { method: "POST", headers: auth, body: JSON.stringify({ days: 11 }) });
|
|
assert.equal(overLimit.status, 422);
|
|
const write = await fetch(`${base}/api/v1/me/webdav/file`, { method: "PUT", headers: auth, body: JSON.stringify({ path: "canvas/manifest.json", mimeType: "application/json", data: "eA==" }) });
|
|
assert.equal(write.status, 202);
|
|
assert.ok(store.webdavFiles.has(`${user.registered.userId}:canvas/manifest.json`));
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("expired buckets append one adjustment ledger entry before reservation", () => {
|
|
const store = createStore();
|
|
const userId = "expired-bucket-user";
|
|
const bucketId = "expired-bucket";
|
|
store.balances.set(userId, { available: 10, reserved: 0 });
|
|
store.buckets.set(userId, [{ id: bucketId, userId, source: "plan", remaining: 10, priority: 0, expiresAt: new Date(Date.now() - 1_000).toISOString() }]);
|
|
assert.equal(reserveBalance(store, userId, 1, "expired-reservation"), false);
|
|
assert.equal(store.buckets.get(userId)?.[0].remaining, 0);
|
|
assert.deepEqual({ available: store.balances.get(userId)?.available, reserved: store.balances.get(userId)?.reserved }, { available: 0, reserved: 0 });
|
|
assert.equal(store.ledger.filter((entry) => entry.idempotencyKey === `expire:${userId}:${bucketId}`).length, 1);
|
|
});
|
|
|
|
test("expired bucket adjustment never drives aggregate balance below zero", () => {
|
|
const store = createStore();
|
|
const userId = "expired-bucket-clamp";
|
|
store.balances.set(userId, { available: 3, reserved: 0 });
|
|
store.buckets.set(userId, [{ id: "expired-large", userId, source: "plan", remaining: 10, priority: 0, expiresAt: new Date(Date.now() - 1_000).toISOString() }]);
|
|
assert.equal(reserveBalance(store, userId, 1, "clamp-reservation"), false);
|
|
assert.deepEqual({ available: store.balances.get(userId)?.available, reserved: store.balances.get(userId)?.reserved }, { available: 0, reserved: 0 });
|
|
assert.equal(store.ledger.find((entry) => entry.idempotencyKey === `expire:${userId}:expired-large`)?.amount, -3);
|
|
});
|
|
|
|
test("task route snapshots follow channel-group priority order", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const user = await registerAndLogin(base, "channel-order@example.com");
|
|
store.balances.set(user.registered.userId, { available: 100, reserved: 0 });
|
|
const group = store.channelGroups.find((item) => item.id === "image-default");
|
|
assert.ok(group);
|
|
const route = group.routes.find((item) => item.publicModelId === "basic-image-v1");
|
|
assert.ok(route);
|
|
route.channels = ["channel-c", "channel-a", "channel-d", "channel-b"].map((channelId, index) => ({ channelId, priorityTier: 1, orderWithinTier: index + 1, retryBudget: 0 }));
|
|
const response = await fetch(`${base}/api/v1/tasks/image`, { method: "POST", headers: { authorization: `Bearer ${user.payload.data.accessToken!}`, "content-type": "application/json", "idempotency-key": "channel-order-task" }, body: JSON.stringify({ modelProductId: "basic-image-v1", prompt: "route snapshot" }) });
|
|
assert.equal(response.status, 202);
|
|
const task = [...store.tasks.values()].find((item) => item.ownerId === user.registered.userId);
|
|
assert.deepEqual(task?.routeSnapshot?.orderedCandidates.map((candidate) => candidate.channelId), ["channel-c", "channel-a", "channel-d", "channel-b"]);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("documented admin singleton and detail method aliases remain callable", async () => {
|
|
const { server, base } = await start();
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const headers = (key: string) => ({ authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json", "idempotency-key": key });
|
|
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { headers: headers("alias-units-get") })).status, 200);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { method: "POST", headers: headers("alias-units-post"), body: JSON.stringify({ name: "金币" }) })).status, 201);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/billing/conversion-rules`, { method: "POST", headers: headers("alias-conversion-post"), body: JSON.stringify({ conversionRate: 1, rounding: "round" }) })).status, 201);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/pricing-rules`, { method: "POST", headers: headers("alias-pricing-post"), body: JSON.stringify({ publicModelId: "basic-image-v1", resolutionId: "low", billingUnit: "output", tiers: [{ minQuantity: 1, unitPrice: 10 }], multiplier: 1, enabled: false }) })).status, 201);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/storage-policies`, { method: "POST", headers: headers("alias-storage-post"), body: JSON.stringify({ retentionDays: 30 }) })).status, 201);
|
|
assert.equal((await fetch(`${base}/api/v1/admin/recharge-orders/unknown-order`, { headers: headers("alias-order-detail") })).status, 404);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("audit actors use the user name and direct administrator email", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const user = await registerAndLogin(base, "audit-actor@example.com");
|
|
const userRecord = store.users.get(user.registered.userId);
|
|
assert.ok(userRecord);
|
|
userRecord.username = "日志用户";
|
|
const now = new Date().toISOString();
|
|
store.audit.push(
|
|
{ id: "audit-user-actor", actorId: userRecord.id, action: "user.password.change", objectType: "user", objectId: userRecord.id, requestId: "audit-user-request", createdAt: now },
|
|
{ id: "audit-admin-actor", actorId: "admin", action: "admin.login.succeeded", objectType: "admin", objectId: "admin", requestId: "audit-admin-request", createdAt: now },
|
|
);
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const response = await fetch(`${base}/api/v1/admin/audit-logs`, { headers: { authorization: `Bearer ${loginPayload.data.accessToken}` } });
|
|
assert.equal(response.status, 200);
|
|
const items = (await response.json() as { data: { items: Array<{ id: string; actorName: string }> } }).data.items;
|
|
assert.equal(items.find((item) => item.id === "audit-user-actor")?.actorName, "日志用户");
|
|
assert.equal(items.find((item) => item.id === "audit-admin-actor")?.actorName, "admin@example.com");
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin system settings are independent, versioned, and apply auth switches immediately", async () => {
|
|
const { server, base } = await start();
|
|
try {
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const loginPayload = await login.json() as { data: { accessToken: string } };
|
|
const authHeaders = { authorization: `Bearer ${loginPayload.data.accessToken}` };
|
|
const initial = await fetch(`${base}/api/v1/admin/settings`, { headers: authHeaders });
|
|
assert.equal(initial.status, 200);
|
|
const initialPayload = await initial.json() as { data: { items: Array<{ version: number; userVerificationRequired: boolean }> } };
|
|
const current = initialPayload.data.items[0];
|
|
assert.ok(current);
|
|
assert.equal(current.userVerificationRequired, true);
|
|
const updated = await fetch(`${base}/api/v1/admin/settings`, { method: "PATCH", headers: { ...authHeaders, "content-type": "application/json", "idempotency-key": "settings-update-1", "if-match": String(current.version) }, body: JSON.stringify({ adminMfaRequired: false, adminCaptchaRequired: false, userMfaRequired: false, userVerificationRequired: false, captchaRequired: false, rateLimitWindowMs: 120000, rateLimitMax: 20, queueMaxConcurrent: 8, auditRetentionDays: 180, auditMaxEntries: 5000 }) });
|
|
assert.equal(updated.status, 200);
|
|
const updatedPayload = await updated.json() as { data: { item: { version: number; userVerificationRequired: boolean; rateLimitWindowMs: number } } };
|
|
assert.equal(updatedPayload.data.item.version, current.version + 1);
|
|
assert.equal(updatedPayload.data.item.userVerificationRequired, false);
|
|
assert.equal(updatedPayload.data.item.rateLimitWindowMs, 120000);
|
|
const policy = await fetch(`${base}/api/v1/auth/policy`);
|
|
assert.deepEqual((await policy.json() as { data: unknown }).data, { captchaRequired: false, userVerificationRequired: false, userMfaRequired: false });
|
|
const stale = await fetch(`${base}/api/v1/admin/settings`, { method: "PATCH", headers: { ...authHeaders, "content-type": "application/json", "idempotency-key": "settings-update-stale", "if-match": String(current.version) }, body: JSON.stringify({ rateLimitMax: 21 }) });
|
|
assert.equal(stale.status, 409);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|
|
|
|
test("admin recharge order PATCH only uses guarded failure/refund transitions", async () => {
|
|
const { store, server, base } = await start();
|
|
try {
|
|
const user = await registerAndLogin(base, "admin-order-patch@example.com");
|
|
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
|
const admin = await login.json() as { data: { accessToken: string } };
|
|
const headers = { authorization: `Bearer ${admin.data.accessToken}`, "content-type": "application/json", "idempotency-key": "admin-order-patch" };
|
|
const orderId = "admin-order-fixture";
|
|
store.rechargeOrders.set(orderId, { id: orderId, userId: user.registered.userId, provider: "mock", status: "pending", fiatAmount: 10, balanceAmount: 10, unitVersion: 1, idempotencyKey: "fixture-order", createdAt: new Date().toISOString() });
|
|
const failed = await fetch(`${base}/api/v1/admin/recharge-orders/${orderId}`, { method: "PATCH", headers: { ...headers, "if-match": "1" }, body: JSON.stringify({ status: "failed" }) });
|
|
assert.equal(failed.status, 200);
|
|
const refund = await fetch(`${base}/api/v1/admin/recharge-orders/${orderId}`, { method: "PATCH", headers: { ...headers, "idempotency-key": "admin-order-refund", "if-match": "2" }, body: JSON.stringify({ status: "refunded" }) });
|
|
assert.equal(refund.status, 409);
|
|
assert.equal(store.balances.get(user.registered.userId)?.available, 0);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
});
|