@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { db } from "./db.js";
|
|
3
|
+
import { parseStoredTags } from "./node-attrs-validate.js";
|
|
3
4
|
|
|
4
5
|
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
5
6
|
//
|
|
@@ -41,7 +42,12 @@ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
41
42
|
catch { /* malformed */ }
|
|
42
43
|
}
|
|
43
44
|
const { config_snapshot, ...rest } = r;
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
...rest,
|
|
47
|
+
tags: parseStoredTags(r.tags),
|
|
48
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
49
|
+
role,
|
|
50
|
+
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
@@ -66,7 +72,7 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
66
72
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
67
73
|
config_path, channels, server, hostname,
|
|
68
74
|
network_id, created_at, updated_at,
|
|
69
|
-
config_snapshot
|
|
75
|
+
config_snapshot, display_name, team, tags, attrs_revision
|
|
70
76
|
FROM nodes WHERE node_id = ?1`,
|
|
71
77
|
TEST_NODE,
|
|
72
78
|
);
|
|
@@ -85,11 +91,17 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
85
91
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
86
92
|
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
93
|
expect(row.role).toBe("host_supervisor");
|
|
88
|
-
// Belt:
|
|
94
|
+
// Belt: the key set is exactly the dashboard-facing contract.
|
|
95
|
+
// NOTE: this file MIRRORS the handler's SQL rather than calling it, so
|
|
96
|
+
// it can drift — it already lagged behind `lifecycle_state` /
|
|
97
|
+
// `avatar_url`, which the handler selects but this mirror does not.
|
|
98
|
+
// Treat a diff here as "re-read the handler", not as proof of the wire
|
|
99
|
+
// shape; node-attrs.test.ts asserts the real HTTP response.
|
|
89
100
|
expect(Object.keys(row).sort()).toEqual([
|
|
90
|
-
"alias", "
|
|
91
|
-
"
|
|
92
|
-
"server", "
|
|
101
|
+
"alias", "attrs_revision", "channels", "config_path", "created_at",
|
|
102
|
+
"display_name", "hostname", "model", "network_id", "node_id",
|
|
103
|
+
"node_name", "role", "runtime", "server", "tags", "team",
|
|
104
|
+
"updated_at",
|
|
93
105
|
]);
|
|
94
106
|
});
|
|
95
107
|
|
|
@@ -102,7 +114,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
102
114
|
);
|
|
103
115
|
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
116
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
118
|
+
display_name, team, tags, attrs_revision
|
|
106
119
|
FROM nodes WHERE node_id = ?1`,
|
|
107
120
|
TEST_NODE,
|
|
108
121
|
)!);
|
|
@@ -113,7 +126,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
113
126
|
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
127
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
128
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
129
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
130
|
+
display_name, team, tags, attrs_revision
|
|
117
131
|
FROM nodes WHERE node_id = ?1`,
|
|
118
132
|
TEST_NODE,
|
|
119
133
|
)!);
|
|
@@ -124,7 +138,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
124
138
|
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
139
|
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
140
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
141
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
142
|
+
display_name, team, tags, attrs_revision
|
|
128
143
|
FROM nodes WHERE node_id = ?1`,
|
|
129
144
|
TEST_NODE,
|
|
130
145
|
)!);
|
|
@@ -141,7 +156,8 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
141
156
|
);
|
|
142
157
|
const rows = db.all<Record<string, unknown>>(
|
|
143
158
|
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
-
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
159
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot,
|
|
160
|
+
display_name, team, tags, attrs_revision
|
|
145
161
|
FROM nodes WHERE network_id = ?1`,
|
|
146
162
|
TEST_NETWORK,
|
|
147
163
|
).map(mapRow);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 登录会话:闲置过期 + 「登录设备」列表 + 退出其他设备。
|
|
2
|
+
//
|
|
3
|
+
// 生产上 user-login 令牌一次登录一条、从不过期也不回收,泄漏任何一条就等于那个账号(多为管理员)。
|
|
4
|
+
// 这里的每一条在 main 上都是红的:main 没有闲置过期(旧令牌永远有效)、登录响应不带 token_id、
|
|
5
|
+
// /api/auth/sessions* 端点不存在。正控:在用的会话、显式 API 令牌、节点令牌照常可用。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { register, createNetworkTokenForNode, createToken } from "./auth.js";
|
|
12
|
+
import { db, hashToken } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-sessions-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "SessionsPassw0rd!";
|
|
18
|
+
let USER = "", userId = "", NET = "", ntok = "", apiToken = "";
|
|
19
|
+
|
|
20
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
21
|
+
async function call(token: string, method: string, path: string, body?: unknown, extra: Record<string, string> = {}) {
|
|
22
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: { ...(token ? auth(token) : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...extra },
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
28
|
+
}
|
|
29
|
+
async function loginAs(label?: string, ua = "anet-test-agent/1.0") {
|
|
30
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW, ...(label ? { client_label: label } : {}) }, { "User-Agent": ua });
|
|
31
|
+
expect(r.status).toBe(200);
|
|
32
|
+
return r.body as { token: string; token_id: string };
|
|
33
|
+
}
|
|
34
|
+
// 把某条令牌的「最近使用」拨回 days 天前(created_at 一起拨,模拟一条很早签发、之后没再用过的令牌)。
|
|
35
|
+
function ageToken(token: string, days: number) {
|
|
36
|
+
db.run(
|
|
37
|
+
"UPDATE api_tokens SET last_used_at = datetime('now', ?1), created_at = datetime('now', ?1) WHERE token_hash = ?2",
|
|
38
|
+
[`-${days} days`, hashToken(token)],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
function neverUsed(token: string, days: number) {
|
|
42
|
+
db.run("UPDATE api_tokens SET last_used_at = NULL, created_at = datetime('now', ?1) WHERE token_hash = ?2", [`-${days} days`, hashToken(token)]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
47
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
48
|
+
process.env.HOST = "127.0.0.1";
|
|
49
|
+
// 这里每个用例都真走 POST /api/auth/login 签发会话;默认的每 IP 登录限流会在第 ~20 次开始 429。
|
|
50
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
51
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
52
|
+
USER = `sess_user_${Date.now()}`;
|
|
53
|
+
const reg = register(USER, PW);
|
|
54
|
+
userId = reg.user!.user_id; NET = reg.network_id!;
|
|
55
|
+
const minted = createNetworkTokenForNode(userId, NET, "sess-node", "node_sess_1");
|
|
56
|
+
expect(minted.ok).toBe(true);
|
|
57
|
+
ntok = minted.token!;
|
|
58
|
+
const api = createToken(userId, "ci-script");
|
|
59
|
+
expect(api.ok).toBe(true);
|
|
60
|
+
apiToken = api.token!;
|
|
61
|
+
const mod: any = await import("./server.js");
|
|
62
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
63
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
64
|
+
}, 30_000);
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
try { hub?.stop?.(true); } catch {}
|
|
68
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("login / register identify the issued token", () => {
|
|
72
|
+
test("login returns token_id of the token it returned (and stays backward-compatible)", async () => {
|
|
73
|
+
const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW });
|
|
74
|
+
expect(r.status).toBe(200);
|
|
75
|
+
expect(r.body.ok).toBe(true);
|
|
76
|
+
expect(typeof r.body.token).toBe("string");
|
|
77
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
78
|
+
const row = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
79
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
80
|
+
});
|
|
81
|
+
test("register returns token_id", async () => {
|
|
82
|
+
const r = await call("", "POST", "/api/auth/register", { username: `sess_reg_${Date.now()}`, password: PW });
|
|
83
|
+
expect(r.status).toBe(200);
|
|
84
|
+
expect(r.body.token_id).toMatch(/^tok_/);
|
|
85
|
+
const row = db.get<any>("SELECT token_id, name FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
|
|
86
|
+
expect(row.token_id).toBe(r.body.token_id);
|
|
87
|
+
expect(row.name).toBe("user-login");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("sliding idle expiry (default 30 days)", () => {
|
|
92
|
+
test("a login token idle past the window is rejected with token_expired", async () => {
|
|
93
|
+
const s = await loginAs();
|
|
94
|
+
ageToken(s.token, 31);
|
|
95
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
96
|
+
expect(r.status).toBe(401);
|
|
97
|
+
expect(r.body.error).toBe("token_expired");
|
|
98
|
+
// 同一条 401 改写也覆盖走统一 requireAuth 的 REST 端点
|
|
99
|
+
const r2 = await call(s.token, "GET", "/api/sessions");
|
|
100
|
+
expect(r2.status).toBe(401);
|
|
101
|
+
expect(r2.body.error).toBe("token_expired");
|
|
102
|
+
});
|
|
103
|
+
test("a login token that was never used counts idle time from created_at", async () => {
|
|
104
|
+
const s = await loginAs();
|
|
105
|
+
neverUsed(s.token, 45);
|
|
106
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
107
|
+
expect(r.status).toBe(401);
|
|
108
|
+
expect(r.body.error).toBe("token_expired");
|
|
109
|
+
});
|
|
110
|
+
test("an old but recently used login token stays valid (the window slides)", async () => {
|
|
111
|
+
const s = await loginAs();
|
|
112
|
+
// 90 天前签发,2 天前还在用
|
|
113
|
+
db.run("UPDATE api_tokens SET created_at = datetime('now', '-90 days'), last_used_at = datetime('now', '-2 days') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
114
|
+
const r = await call(s.token, "GET", "/api/auth/me");
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
// 使用把 last_used_at 推到现在(超过节流间隔,必须写)
|
|
117
|
+
const row = db.get<any>("SELECT last_used_at >= datetime('now', '-60 seconds') AS fresh FROM api_tokens WHERE token_hash = ?1", hashToken(s.token));
|
|
118
|
+
expect(row.fresh).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
test("last_used_at is written at most once per hour per token", async () => {
|
|
121
|
+
const s = await loginAs();
|
|
122
|
+
db.run("UPDATE api_tokens SET last_used_at = datetime('now', '-10 minutes') WHERE token_hash = ?1", [hashToken(s.token)]);
|
|
123
|
+
const before = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
124
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
125
|
+
const after = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
|
|
126
|
+
expect(after).toBe(before);
|
|
127
|
+
});
|
|
128
|
+
test("a truly invalid token still says invalid, not expired", async () => {
|
|
129
|
+
const r = await call("utok_00000000000000000000000000000000", "GET", "/api/auth/me");
|
|
130
|
+
expect(r.status).toBe(401);
|
|
131
|
+
expect(r.body.error).not.toBe("token_expired");
|
|
132
|
+
});
|
|
133
|
+
test("node tokens and explicit API tokens never idle-expire", async () => {
|
|
134
|
+
neverUsed(ntok, 400);
|
|
135
|
+
neverUsed(apiToken, 400);
|
|
136
|
+
const n = await call(ntok, "GET", "/api/auth/me");
|
|
137
|
+
expect(n.status).toBe(200);
|
|
138
|
+
expect(n.body.credential.kind).toBe("node");
|
|
139
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
test("COMMHUB_SESSION_IDLE_DAYS configures the window; 0 disables it", async () => {
|
|
142
|
+
const s = await loginAs();
|
|
143
|
+
ageToken(s.token, 10);
|
|
144
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "7";
|
|
145
|
+
try {
|
|
146
|
+
expect((await call(s.token, "GET", "/api/auth/me")).body.error).toBe("token_expired");
|
|
147
|
+
process.env.COMMHUB_SESSION_IDLE_DAYS = "0";
|
|
148
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
149
|
+
} finally {
|
|
150
|
+
delete process.env.COMMHUB_SESSION_IDLE_DAYS;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("GET /api/auth/sessions", () => {
|
|
156
|
+
test("lists my live login sessions with label, user agent and is_current", async () => {
|
|
157
|
+
const a = await loginAs("Android · 0.2.150", "okhttp/4");
|
|
158
|
+
const b = await loginAs("macOS · 0.2.150", "Mozilla/5.0 (Macintosh)");
|
|
159
|
+
const dead = await loginAs("stale");
|
|
160
|
+
ageToken(dead.token, 40);
|
|
161
|
+
const r = await call(b.token, "GET", "/api/auth/sessions");
|
|
162
|
+
expect(r.status).toBe(200);
|
|
163
|
+
expect(r.body.idle_timeout_days).toBe(30);
|
|
164
|
+
expect(r.body.current_token_id).toBe(b.token_id);
|
|
165
|
+
const ids = r.body.sessions.map((x: any) => x.token_id);
|
|
166
|
+
expect(ids).toContain(a.token_id);
|
|
167
|
+
expect(ids).toContain(b.token_id);
|
|
168
|
+
expect(ids).not.toContain(dead.token_id);
|
|
169
|
+
const me = r.body.sessions.find((x: any) => x.token_id === b.token_id);
|
|
170
|
+
expect(me.is_current).toBe(true);
|
|
171
|
+
expect(me.client_label).toBe("macOS · 0.2.150");
|
|
172
|
+
expect(me.user_agent).toBe("Mozilla/5.0 (Macintosh)");
|
|
173
|
+
expect(typeof me.created_at).toBe("string");
|
|
174
|
+
const other = r.body.sessions.find((x: any) => x.token_id === a.token_id);
|
|
175
|
+
expect(other.is_current).toBe(false);
|
|
176
|
+
expect(other.client_label).toBe("Android · 0.2.150");
|
|
177
|
+
// 不泄漏令牌本身或哈希;API 令牌和节点令牌不在列表里
|
|
178
|
+
expect(JSON.stringify(r.body)).not.toContain("token_hash");
|
|
179
|
+
expect(JSON.stringify(r.body)).not.toContain(b.token);
|
|
180
|
+
expect(r.body.sessions.every((x: any) => x.name !== "ci-script" && !String(x.name).startsWith("node:"))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
test("node tokens get 403 user_token_required", async () => {
|
|
183
|
+
const r = await call(ntok, "GET", "/api/auth/sessions");
|
|
184
|
+
expect(r.status).toBe(403);
|
|
185
|
+
expect(r.body.error).toBe("user_token_required");
|
|
186
|
+
});
|
|
187
|
+
test("no token → 401", async () => {
|
|
188
|
+
expect((await call("", "GET", "/api/auth/sessions")).status).toBe(401);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("revoking sessions", () => {
|
|
193
|
+
test("revoke-others keeps the current token and leaves API and node tokens alone", async () => {
|
|
194
|
+
const keep = await loginAs("keep");
|
|
195
|
+
const other1 = await loginAs("other1");
|
|
196
|
+
const other2 = await loginAs("other2");
|
|
197
|
+
const r = await call(keep.token, "POST", "/api/auth/sessions/revoke-others");
|
|
198
|
+
expect(r.status).toBe(200);
|
|
199
|
+
expect(r.body.kept_token_id).toBe(keep.token_id);
|
|
200
|
+
expect(r.body.revoked).toBeGreaterThanOrEqual(2);
|
|
201
|
+
expect((await call(keep.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
202
|
+
expect((await call(other1.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
203
|
+
expect((await call(other2.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
204
|
+
expect((await call(ntok, "GET", "/api/auth/me")).status).toBe(200);
|
|
205
|
+
expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
|
|
206
|
+
const list = await call(keep.token, "GET", "/api/auth/sessions");
|
|
207
|
+
expect(list.body.sessions.map((x: any) => x.token_id)).toEqual([keep.token_id]);
|
|
208
|
+
const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'sessions_revoked_others' AND target_id = ?1", keep.token_id);
|
|
209
|
+
expect(audit?.detail).toMatch(/^revoked=\d+$/);
|
|
210
|
+
});
|
|
211
|
+
test("revoke-others refuses node tokens and non-session tokens", async () => {
|
|
212
|
+
const r = await call(ntok, "POST", "/api/auth/sessions/revoke-others");
|
|
213
|
+
expect(r.status).toBe(403);
|
|
214
|
+
expect(r.body.error).toBe("user_token_required");
|
|
215
|
+
const s = await loginAs("survivor");
|
|
216
|
+
const viaApi = await call(apiToken, "POST", "/api/auth/sessions/revoke-others");
|
|
217
|
+
expect(viaApi.status).toBe(403);
|
|
218
|
+
expect(viaApi.body.error).toBe("session_token_required");
|
|
219
|
+
expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
220
|
+
});
|
|
221
|
+
test("DELETE /api/auth/sessions/:id revokes one other session", async () => {
|
|
222
|
+
const me = await loginAs("me");
|
|
223
|
+
const victim = await loginAs("victim");
|
|
224
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${victim.token_id}`);
|
|
225
|
+
expect(r.status).toBe(200);
|
|
226
|
+
expect(r.body.was_current).toBe(false);
|
|
227
|
+
expect((await call(victim.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
228
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
229
|
+
expect(db.get<any>("SELECT 1 AS x FROM audit_log WHERE action = 'session_revoked' AND target_id = ?1", victim.token_id)?.x).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
test("DELETE of my own session is a logout", async () => {
|
|
232
|
+
const me = await loginAs("logout");
|
|
233
|
+
const r = await call(me.token, "DELETE", `/api/auth/sessions/${me.token_id}`);
|
|
234
|
+
expect(r.status).toBe(200);
|
|
235
|
+
expect(r.body.was_current).toBe(true);
|
|
236
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(401);
|
|
237
|
+
});
|
|
238
|
+
test("DELETE cannot reach API tokens, node tokens, or another user's session", async () => {
|
|
239
|
+
const me = await loginAs("guard");
|
|
240
|
+
const apiRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(apiToken));
|
|
241
|
+
const ntokRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(ntok));
|
|
242
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${apiRow.token_id}`)).status).toBe(404);
|
|
243
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${ntokRow.token_id}`)).status).toBe(404);
|
|
244
|
+
const stranger = register(`sess_stranger_${Date.now()}`, PW);
|
|
245
|
+
expect((await call(me.token, "DELETE", `/api/auth/sessions/${stranger.token_id}`)).status).toBe(404);
|
|
246
|
+
expect((await call(stranger.token!, "GET", "/api/auth/me")).status).toBe(200);
|
|
247
|
+
expect((await call(ntok, "DELETE", `/api/auth/sessions/${me.token_id}`)).status).toBe(403);
|
|
248
|
+
expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
|
|
|
10
10
|
|
|
11
11
|
// First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
|
|
12
12
|
// To test full-strength rules, seed a dummy admin first.
|
|
13
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
14
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
15
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
16
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
13
17
|
beforeAll(() => {
|
|
14
18
|
// Idempotent: if DB file already has users (from a previous run on the
|
|
15
19
|
// same temp path), this errors with "username already taken" → that's
|
|
16
20
|
// fine, the goal is just "ensure at least one user exists so subsequent
|
|
17
21
|
// registers hit the strict path".
|
|
18
22
|
register("_seed_admin", "BootstrapPw1", undefined, "seed");
|
|
19
|
-
});
|
|
23
|
+
}, 30_000);
|
|
20
24
|
|
|
21
25
|
describe("register — username rules", () => {
|
|
22
26
|
it("rejects empty username", () => {
|