Files

120 lines
7.2 KiB
JavaScript

import assert from "node:assert/strict";
import { dbGet, dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email, deviceId, deviceLabel) {
const result = await request("/api/auth/login", {
method: "POST",
headers: { "x-device-id": deviceId, "x-device-label": deviceLabel },
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.status, 200, `${email} 登录失败`);
assert.ok(result.payload.session?.token, `${email} 未返回登录会话`);
return {
authorization: `Bearer ${result.payload.session.token}`,
session: result.payload.session
};
}
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const ownerDeviceId = `smoke-owner-device-${suffix}`;
const secondDeviceId = `smoke-owner-device-2-${suffix}`;
const ownerLabel = "Device Risk Smoke Browser";
const owner = await login("producer@local.test", ownerDeviceId, ownerLabel);
let clientId = "";
let clientKey = "";
try {
assert.equal(owner.session.riskLevel, "high", "首次出现的设备应该标记为高风险");
assert.ok(owner.session.riskScore >= 75, "首次设备风险分数不足");
assert.ok(!JSON.stringify(owner.session).includes(ownerDeviceId), "登录响应不能返回原始设备 ID");
const firstDevices = await request("/api/auth/devices", { headers: owner });
assert.equal(firstDevices.response.status, 200, "登录用户无法读取自己的设备列表");
assert.ok(Array.isArray(firstDevices.payload.devices), "设备接口必须返回 devices 数组");
const firstDevice = firstDevices.payload.devices.find((device) => device.label === ownerLabel);
assert.ok(firstDevice, "首次登录没有登记设备");
assert.equal(firstDevice.status, "known", "首次设备默认不能直接标记为信任");
assert.ok(!JSON.stringify(firstDevice).includes(ownerDeviceId), "设备接口不能返回原始设备 ID");
assert.ok(!Object.keys(firstDevice).some((key) => /(hash|token|secret|key)/i.test(key)), "设备接口不能返回设备摘要或令牌字段");
const storedDevice = dbGet("SELECT device_key_hash, fingerprint_hash FROM auth_devices WHERE id = ?", [firstDevice.id]);
assert.ok(storedDevice && /^[a-f0-9]{64}$/i.test(storedDevice.device_key_hash), "数据库必须保存设备 ID 的摘要");
assert.notEqual(storedDevice.device_key_hash, ownerDeviceId, "数据库不能保存原始设备 ID");
const secondLogin = await login("producer@local.test", ownerDeviceId, ownerLabel);
assert.ok(secondLogin.session.riskScore < owner.session.riskScore, "同设备再次登录的风险分数应该下降");
assert.equal(secondLogin.session.riskLevel, "medium", "已知但未信任的设备应该是中风险");
const trusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/trust`, { method: "POST", headers: owner, body: "{}" });
assert.equal(trusted.response.status, 200, "信任设备失败");
assert.equal(trusted.payload.device.status, "trusted", "设备信任状态没有落库");
assert.ok(trusted.payload.devices.some((device) => device.id === firstDevice.id && device.status === "trusted"), "设备列表没有返回信任状态");
const trustedLogin = await login("producer@local.test", ownerDeviceId, ownerLabel);
assert.equal(trustedLogin.session.riskLevel, "low", "信任设备再次登录应该是低风险");
assert.ok(trustedLogin.session.riskScore < secondLogin.session.riskScore, "信任设备风险分数应该继续下降");
const untrusted = await request(`/api/auth/devices/${encodeURIComponent(firstDevice.id)}/untrust`, { method: "POST", headers: owner, body: "{}" });
assert.equal(untrusted.response.status, 200, "取消设备信任失败");
assert.equal(untrusted.payload.device.status, "known", "取消信任后设备状态不正确");
const secondDeviceLogin = await login("producer@local.test", secondDeviceId, "Second Smoke Browser");
assert.equal(secondDeviceLogin.session.riskLevel, "high", "新设备应该重新标记为高风险");
const ownerEvents = await request("/api/auth/security-events", { headers: owner });
assert.equal(ownerEvents.response.status, 200, "无法读取设备安全事件");
const eventTypes = new Set(ownerEvents.payload.events.map((event) => event.eventType));
assert.ok(eventTypes.has("device.first_seen"), "安全事件缺少 device.first_seen");
assert.ok(eventTypes.has("device.trusted"), "安全事件缺少 device.trusted");
assert.ok(eventTypes.has("device.untrusted"), "安全事件缺少 device.untrusted");
assert.ok(!JSON.stringify(ownerEvents.payload.events).includes(ownerDeviceId), "安全事件不能记录原始设备 ID");
const systemDetail = await request("/api/system/users/u-owner", { headers: owner });
assert.equal(systemDetail.response.status, 200, "系统管理员无法读取自己的设备风险详情");
assert.ok(Array.isArray(systemDetail.payload.devices), "系统用户详情缺少设备风险列表");
assert.ok(systemDetail.payload.devices.some((device) => device.latestRiskLevel === "high"), "系统用户详情没有显示高风险设备");
assert.ok(systemDetail.payload.sessions.some((session) => session.riskLevel && session.device), "系统用户详情会话缺少设备风险字段");
assert.ok(!JSON.stringify(systemDetail.payload).includes(ownerDeviceId), "系统用户详情不能返回原始设备 ID");
const deniedWriter = await login("writer@local.test", `smoke-writer-device-${suffix}`, "Writer Smoke Browser");
const ordinarySystemDenied = await request("/api/system/users/u-owner", { headers: deniedWriter });
assert.equal(ordinarySystemDenied.response.status, 403, "普通用户不能访问系统用户设备详情");
const createdClient = await request("/api/system/api-clients", {
method: "POST",
headers: {
authorization: owner.authorization,
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama"
},
body: JSON.stringify({ name: `Device Risk Smoke ${suffix}`, scopes: ["jobs:read"] })
});
assert.equal(createdClient.response.status, 201, "创建设备风险 smoke API Client 失败");
clientId = createdClient.payload.client.id;
clientKey = createdClient.payload.clientKey;
assert.ok(clientKey, "设备风险 smoke 没有拿到 API Client 密钥");
const apiClientDenied = await request("/api/auth/devices", { headers: { authorization: `Bearer ${clientKey}` } });
assert.equal(apiClientDenied.response.status, 403, "API Client 不能管理浏览器设备");
assert.equal(apiClientDenied.payload.error, "device_management_unavailable", "设备管理 API Client 错误码不稳定");
console.log(`device risk smoke passed: registration, trust boundary, risk scoring, and sensitive-field checks (${firstDevice.id})`);
} finally {
if (clientId) {
withTransaction(() => {
dbRun("DELETE FROM api_clients WHERE id = ?", [clientId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [clientId]);
});
}
}