125 lines
7.5 KiB
JavaScript
125 lines
7.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
|
|
import { hashApiClientKey } from "../server/api-client-secrets.mjs";
|
|
|
|
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
|
|
const scope = {
|
|
"x-organization-id": "org-studio-lab",
|
|
"x-workspace-id": "ws-local-aidrama",
|
|
"x-project-id": "thunder-mouth"
|
|
};
|
|
|
|
async function request(path, headers = {}, options = {}) {
|
|
const response = await fetch(`${api}${path}`, {
|
|
...options,
|
|
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
|
|
});
|
|
const payload = await response.json().catch(() => ({}));
|
|
return { response, payload };
|
|
}
|
|
|
|
async function login(email) {
|
|
const result = await request("/api/auth/login", {}, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
|
|
assert.equal(result.response.ok, true, `${email} 登录失败`);
|
|
return { authorization: `Bearer ${result.payload.session.token}` };
|
|
}
|
|
|
|
const owner = { ...(await login("producer@local.test")), ...scope };
|
|
const writer = { ...(await login("writer@local.test")), ...scope };
|
|
let clientId = "";
|
|
let firstKey = "";
|
|
let modelId = "";
|
|
try {
|
|
const denied = await request("/api/system/api-clients", writer);
|
|
assert.equal(denied.response.status, 403, "普通用户不应访问 API 客户端目录");
|
|
|
|
const invalidScopes = await request("/api/system/api-clients", owner, {
|
|
method: "POST",
|
|
body: JSON.stringify({ name: `Invalid Scope ${Date.now()}`, scopes: ["admin:all"] })
|
|
});
|
|
assert.equal(invalidScopes.response.status, 400, "不支持的 API scope 必须被拒绝");
|
|
assert.equal(invalidScopes.payload.error, "api_client_scopes_invalid", "不支持的 API scope 错误码必须稳定");
|
|
|
|
const created = await request("/api/system/api-clients", owner, {
|
|
method: "POST",
|
|
body: JSON.stringify({ name: `Smoke API Client ${Date.now()}`, scopes: ["jobs:read", "models:read"] })
|
|
});
|
|
assert.equal(created.response.status, 201, "系统管理员创建 API 客户端失败");
|
|
clientId = created.payload.client.id;
|
|
firstKey = created.payload.clientKey;
|
|
assert.ok(firstKey.startsWith("local-") && firstKey.length > 24, "新 API 密钥熵不足或格式错误");
|
|
const stored = dbGet("SELECT client_key, client_key_hash, client_key_prefix, key_version FROM api_clients WHERE id = ?", [clientId]);
|
|
assert.ok(stored, "新 API 客户端没有写入数据库");
|
|
assert.equal(stored.client_key_hash, hashApiClientKey(firstKey), "数据库中的 API 密钥摘要不匹配");
|
|
assert.equal(stored.key_version, 2, "API 客户端密钥版本没有升级");
|
|
assert.equal(stored.client_key_prefix, firstKey.slice(0, 12), "API 密钥预览前缀不匹配");
|
|
assert.notEqual(stored.client_key, firstKey, "数据库不能保存 API 密钥明文");
|
|
assert.ok(!String(stored.client_key).includes(firstKey), "数据库密钥字段不能包含可用密钥");
|
|
|
|
const listed = await request("/api/system/api-clients", owner);
|
|
assert.equal(listed.response.ok, true, "系统管理员读取 API 客户端目录失败");
|
|
const listedClient = listed.payload.apiClients.find((item) => item.id === clientId);
|
|
assert.ok(listedClient, "新 API 客户端没有出现在目录中");
|
|
assert.ok(!JSON.stringify(listedClient).includes(firstKey), "API 客户端目录不能返回完整密钥");
|
|
assert.deepEqual(listedClient.scopes, ["jobs:read", "models:read"], "API 客户端目录必须返回真实 scope");
|
|
|
|
const clientJobs = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope });
|
|
assert.equal(clientJobs.response.ok, true, "新 API 密钥不能访问已授权任务读取接口");
|
|
const firstJobId = clientJobs.payload.jobs?.[0]?.id;
|
|
if (firstJobId) {
|
|
const clientJobDetail = await request(`/api/jobs/${encodeURIComponent(firstJobId)}`, { authorization: `Bearer ${firstKey}`, ...scope });
|
|
assert.equal(clientJobDetail.response.ok, true, "jobs:read 必须允许读取任务详情");
|
|
}
|
|
const readOnlyCreate = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "POST", body: "{}" });
|
|
assert.equal(readOnlyCreate.response.status, 403, "jobs:read 不能创建生成任务");
|
|
assert.equal(readOnlyCreate.payload.error, "api_client_scope_denied", "缺少 jobs:write 时必须返回 scope 错误");
|
|
|
|
const modelRead = await request("/api/platform/models", { authorization: `Bearer ${firstKey}`, ...scope });
|
|
assert.equal(modelRead.response.ok, true, "models:read 必须允许读取模型连接器");
|
|
const modelWriteDenied = await request("/api/platform/models/owned-image", { authorization: `Bearer ${firstKey}`, ...scope }, { method: "PATCH", body: JSON.stringify({}) });
|
|
assert.equal(modelWriteDenied.response.status, 403, "models:read 不能修改模型连接器");
|
|
assert.equal(modelWriteDenied.payload.error, "api_client_scope_denied", "缺少 models:write 时必须返回 scope 错误");
|
|
|
|
const scopeUpdate = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}`, owner, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ scopes: ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"] })
|
|
});
|
|
assert.equal(scopeUpdate.response.ok, true, "API 客户端 scope 更新失败");
|
|
assert.deepEqual(scopeUpdate.payload.client.scopes, ["jobs:read", "jobs:write", "models:read", "models:write", "audit:read"], "API 客户端 scope 更新结果不正确");
|
|
|
|
const writeKey = firstKey;
|
|
const dryRun = await request("/api/adapters/dry-run", { authorization: `Bearer ${writeKey}`, ...scope }, { method: "POST", body: JSON.stringify({ shotId: "shot-01", adapterId: "owned-image" }) });
|
|
assert.equal(dryRun.response.ok, true, "jobs:write 必须允许生成请求 dry-run");
|
|
const auditRead = await request("/api/audit", { authorization: `Bearer ${writeKey}`, ...scope });
|
|
assert.equal(auditRead.response.ok, true, "audit:read 必须允许读取审计日志");
|
|
|
|
modelId = `smoke-scope-model-${Date.now()}`;
|
|
const modelWrite = await request("/api/platform/models/register", { authorization: `Bearer ${writeKey}`, ...scope }, {
|
|
method: "POST",
|
|
body: JSON.stringify({ id: modelId, label: "Scope 回归本地模型", endpoint: "http://127.0.0.1:7879", kind: "http-json", capability: ["text-to-image"], costMode: "local" })
|
|
});
|
|
assert.equal(modelWrite.response.status, 201, "models:write 必须允许登记模型连接器");
|
|
|
|
const rotated = await request(`/api/system/api-clients/${encodeURIComponent(clientId)}/rotate`, owner, { method: "POST", body: "{}" });
|
|
assert.equal(rotated.response.ok, true, "API 密钥轮换失败");
|
|
assert.notEqual(rotated.payload.clientKey, firstKey, "轮换后的 API 密钥不能与旧密钥相同");
|
|
|
|
const oldKey = await request("/api/jobs", { authorization: `Bearer ${firstKey}`, ...scope });
|
|
assert.equal(oldKey.response.status, 401, "旧 API 密钥轮换后必须立即失效");
|
|
const newKey = await request("/api/jobs", { authorization: `Bearer ${rotated.payload.clientKey}`, ...scope });
|
|
assert.equal(newKey.response.ok, true, "轮换后的 API 密钥不能访问已授权任务读取接口");
|
|
|
|
console.log(`api client smoke passed: scoped access, one-time rotation, old-key revocation (${clientId})`);
|
|
} finally {
|
|
if (clientId) {
|
|
withTransaction(() => {
|
|
if (modelId) {
|
|
dbRun("DELETE FROM model_connectors WHERE id = ?", [modelId]);
|
|
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [modelId]);
|
|
}
|
|
dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]);
|
|
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]);
|
|
});
|
|
}
|
|
}
|