@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.81
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 +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -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 +450 -31
- 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/commhub-bin-args.test.ts +85 -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 +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -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/dm-file-scope-http.test.ts +178 -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 +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -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-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -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/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +284 -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-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1443 -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 +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- 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-reply-unread-http.test.ts +212 -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 +638 -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 +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -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 +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +317 -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 +3534 -343
- 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 +138 -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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import "./require-explicit-test-db.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { daemonDefaultWorkdirRoot, validateWorkdir, ValidationError } from "./create-node-validate.js";
|
|
7
|
+
import { addNetworkMember, login, register } from "./auth.js";
|
|
8
|
+
|
|
9
|
+
// app「新建节点」工作目录 —— hub 这一层管三件事:
|
|
10
|
+
// ① node_spec.workdir 的**形状**(绝对 / ~/… / C:\…),派发前就拒
|
|
11
|
+
// ② daemon 没自报 default_workdir_root(= 老 daemon,会静默忽略 workdir)⇒ 拒,不派
|
|
12
|
+
// ③ 请求行存下 workdir,get_create_request 原样交给 daemon;不带就**不出现**这个键
|
|
13
|
+
// 以及 /api/host-supervisors 只给 admin/owner 透出默认根(它是那台机器的家目录路径)。
|
|
14
|
+
|
|
15
|
+
const NET = "net_workdir_gate";
|
|
16
|
+
const USER_ID = "u_workdir_admin";
|
|
17
|
+
const DAEMON_ID = "node_workdir_daemon";
|
|
18
|
+
const DAEMON_ALIAS = "workdir-daemon";
|
|
19
|
+
const DAEMON_TOK = "tok_workdir_daemon";
|
|
20
|
+
const ROOT = "/home/user";
|
|
21
|
+
|
|
22
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
23
|
+
interface Reply { ok?: boolean; error?: string; request_id?: string; node_spec?: any; [k: string]: unknown; }
|
|
24
|
+
|
|
25
|
+
function cleanup() {
|
|
26
|
+
for (const t of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
|
|
27
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
28
|
+
}
|
|
29
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
beforeEach(cleanup);
|
|
33
|
+
afterAll(cleanup);
|
|
34
|
+
|
|
35
|
+
function seed(caps: Record<string, unknown>) {
|
|
36
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [USER_ID]);
|
|
37
|
+
db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER_ID]);
|
|
38
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'admin', datetime('now'))`, [USER_ID, NET]);
|
|
39
|
+
db.run(
|
|
40
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
41
|
+
VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
|
|
42
|
+
[DAEMON_ID, DAEMON_ALIAS, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], ...caps } })],
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
46
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
47
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tools(callerTokenId: string | null = null): Record<string, ToolHandler> {
|
|
52
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
53
|
+
const out: Record<string, ToolHandler> = {};
|
|
54
|
+
const origTool = server.tool.bind(server);
|
|
55
|
+
server.tool = (name: string, d: string, schema: any, handler: ToolHandler) => { out[name] = handler; return origTool(name, d, schema, handler); };
|
|
56
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
57
|
+
if (origRegisterTool) {
|
|
58
|
+
server.registerTool = (name: string, cfg: any, handler: ToolHandler) => { out[name] = handler; return origRegisterTool(name, cfg, handler); };
|
|
59
|
+
}
|
|
60
|
+
registerTools(server, undefined, NET, USER_ID, null, true, callerTokenId);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function call(h: ToolHandler, args: any): Promise<Reply> {
|
|
65
|
+
return JSON.parse((await h(args)).content[0].text) as Reply;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseSpec = { name: "wd-child", runtime: "claude-agent-sdk", model: "claude-sonnet-4-5", flags: {} };
|
|
69
|
+
|
|
70
|
+
function rowCount(): number {
|
|
71
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1", DAEMON_ID)?.n ?? -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("validateWorkdir — shape only", () => {
|
|
75
|
+
const ok = ["/home/user/proj", "/srv/x", "~", "~/proj", "C:\\Users\\alice\\proj", "D:/work", " /padded "];
|
|
76
|
+
for (const v of ok) test(`accepts ${JSON.stringify(v)}`, () => { expect(() => validateWorkdir(v)).not.toThrow(); });
|
|
77
|
+
test("accepts undefined / null (field omitted)", () => {
|
|
78
|
+
expect(() => validateWorkdir(undefined)).not.toThrow();
|
|
79
|
+
expect(() => validateWorkdir(null)).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
const bad: unknown[] = ["", " ", "proj", "./proj", "../x", "~alice/x", "C:proj", "/a\nb", "/a\u0000b", "x".repeat(10) + "/", "/" + "a".repeat(1024), 42, {}];
|
|
82
|
+
for (const v of bad) {
|
|
83
|
+
test(`rejects ${JSON.stringify(v)?.slice(0, 40)}`, () => {
|
|
84
|
+
let code = "";
|
|
85
|
+
try { validateWorkdir(v); } catch (e) { code = (e as ValidationError).code; }
|
|
86
|
+
expect(code).toBe("workdir_invalid");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("daemonDefaultWorkdirRoot — sanitize the self-report", () => {
|
|
92
|
+
const snap = (v: unknown) => JSON.stringify({ daemon_capabilities: { default_workdir_root: v } });
|
|
93
|
+
test("absolute posix root passes through", () => { expect(daemonDefaultWorkdirRoot(snap(ROOT))).toBe(ROOT); });
|
|
94
|
+
test("windows root passes through", () => { expect(daemonDefaultWorkdirRoot(snap("C:\\Users\\alice"))).toBe("C:\\Users\\alice"); });
|
|
95
|
+
test("object snapshot (not string) also accepted", () => {
|
|
96
|
+
expect(daemonDefaultWorkdirRoot({ daemon_capabilities: { default_workdir_root: ROOT } })).toBe(ROOT);
|
|
97
|
+
});
|
|
98
|
+
for (const v of [undefined, null, "", "relative", "~/x", 7, "/a\nb", "/" + "a".repeat(1100)]) {
|
|
99
|
+
test(`not reported / malformed ${JSON.stringify(v)?.slice(0, 30)} → null`, () => {
|
|
100
|
+
expect(daemonDefaultWorkdirRoot(snap(v))).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
test("null / garbage snapshot → null", () => {
|
|
104
|
+
expect(daemonDefaultWorkdirRoot(null)).toBeNull();
|
|
105
|
+
expect(daemonDefaultWorkdirRoot("{not json")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("create_node + workdir", () => {
|
|
110
|
+
test("🔴 old daemon (no default_workdir_root) + workdir → workdir_not_supported_by_daemon, NO row", async () => {
|
|
111
|
+
seed({});
|
|
112
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: `${ROOT}/wd-child` } });
|
|
113
|
+
expect(r.ok).toBe(false);
|
|
114
|
+
expect(r.error).toBe("workdir_not_supported_by_daemon");
|
|
115
|
+
expect(rowCount()).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("old daemon WITHOUT workdir still dispatches (no regression for existing callers)", async () => {
|
|
119
|
+
seed({});
|
|
120
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
121
|
+
expect(r.ok).toBe(true);
|
|
122
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
123
|
+
expect(row?.workdir).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("bad shape rejected before dispatch → workdir_invalid, NO row", async () => {
|
|
127
|
+
seed({ default_workdir_root: ROOT });
|
|
128
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: "relative/dir" } });
|
|
129
|
+
expect(r.error).toBe("workdir_invalid");
|
|
130
|
+
expect(rowCount()).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("new daemon + workdir → row stores it and get_create_request hands it to the daemon", async () => {
|
|
134
|
+
seed({ default_workdir_root: ROOT });
|
|
135
|
+
const wd = `${ROOT}/wd-child`;
|
|
136
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
137
|
+
expect(r.ok).toBe(true);
|
|
138
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
139
|
+
expect(row?.workdir).toBe(wd);
|
|
140
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
141
|
+
expect(g.ok).toBe(true);
|
|
142
|
+
expect(g.node_spec.workdir).toBe(wd);
|
|
143
|
+
expect(g.node_spec.name).toBe(baseSpec.name);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("workdir is trimmed before storage", async () => {
|
|
147
|
+
seed({ default_workdir_root: ROOT });
|
|
148
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: ` ~/wd-child ` } });
|
|
149
|
+
expect(r.ok).toBe(true);
|
|
150
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
151
|
+
expect(row?.workdir).toBe("~/wd-child");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("new daemon WITHOUT workdir → get_create_request node_spec has NO workdir key (daemon keeps legacy layout)", async () => {
|
|
155
|
+
seed({ default_workdir_root: ROOT });
|
|
156
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
157
|
+
expect(r.ok).toBe(true);
|
|
158
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
159
|
+
expect(g.ok).toBe(true);
|
|
160
|
+
expect("workdir" in g.node_spec).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("audit detail records the requested workdir", async () => {
|
|
164
|
+
seed({ default_workdir_root: ROOT });
|
|
165
|
+
const wd = `${ROOT}/wd-child`;
|
|
166
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
167
|
+
const a = db.get<{ detail: string }>(
|
|
168
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'create_node_dispatch_attempted'", r.request_id,
|
|
169
|
+
);
|
|
170
|
+
expect(JSON.parse(a?.detail || "{}").workdir).toBe(wd);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("list_host_supervisors (MCP) surfaces default_workdir_root for admin", async () => {
|
|
174
|
+
seed({ default_workdir_root: ROOT });
|
|
175
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
176
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
177
|
+
expect(d.default_workdir_root).toBe(ROOT);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("list_host_supervisors (MCP) omits the key for an old daemon", async () => {
|
|
181
|
+
seed({});
|
|
182
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
183
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
184
|
+
expect(d).toBeDefined();
|
|
185
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── REST /api/host-supervisors: admin sees the root, member does not ──────────
|
|
190
|
+
let BASE = "";
|
|
191
|
+
let server: any = null;
|
|
192
|
+
let ownerTok = "", memberTok = "", restNet = "", ownerId = "";
|
|
193
|
+
describe("/api/host-supervisors — default_workdir_root visibility", () => {
|
|
194
|
+
beforeAll(async () => {
|
|
195
|
+
process.env.HOST = "127.0.0.1";
|
|
196
|
+
const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
197
|
+
const pw = "BootstrapPw123Aa!";
|
|
198
|
+
const s = register(`wd_seed_${sfx}`, pw, undefined, "seed");
|
|
199
|
+
if (!s.ok) throw new Error("seed failed");
|
|
200
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`wd_seed_${sfx}`]);
|
|
201
|
+
const o = register(`wd_owner_${sfx}`, pw, undefined, "seed");
|
|
202
|
+
if (!o.ok || !o.token) throw new Error("owner register failed");
|
|
203
|
+
ownerTok = o.token; restNet = o.network_id ?? "";
|
|
204
|
+
ownerId = login(`wd_owner_${sfx}`, pw).user!.user_id;
|
|
205
|
+
const m = register(`wd_member_${sfx}`, pw, undefined, "seed");
|
|
206
|
+
if (!m.ok || !m.token) throw new Error("member register failed");
|
|
207
|
+
memberTok = m.token;
|
|
208
|
+
const memberId = login(`wd_member_${sfx}`, pw).user!.user_id;
|
|
209
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
210
|
+
const add = addNetworkMember(restNet, memberId, "member", undefined, { agentAccess: "all" });
|
|
211
|
+
if (!add.ok) throw new Error("add member failed: " + add.error);
|
|
212
|
+
const { bootServer } = await import("./server.js");
|
|
213
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
214
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
215
|
+
for (const [alias, caps] of [["wd-new", { default_workdir_root: ROOT }], ["wd-old", {}]] as const) {
|
|
216
|
+
db.run(
|
|
217
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, config_revision, config_snapshot)
|
|
218
|
+
VALUES (?1, ?2, ?2, 'claude-agent-sdk', 'm', '/tmp/cfg.json', '[]', 'h', 'h', ?3, 0, ?4)`,
|
|
219
|
+
[`wd_n_${alias}`, alias, restNet, JSON.stringify({ role: "host_supervisor", daemon_capabilities: caps })],
|
|
220
|
+
);
|
|
221
|
+
db.run(
|
|
222
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')`,
|
|
223
|
+
[`wd_t_${alias}`, `hash_wd_${alias}`, ownerId, restNet, `node:${alias}`],
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
afterAll(() => {
|
|
228
|
+
try { server?.stop?.(true); } catch {}
|
|
229
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [restNet]); } catch {}
|
|
230
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [restNet]); } catch {}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
async function list(tok: string): Promise<any[]> {
|
|
234
|
+
const res = await fetch(`${BASE}/api/host-supervisors?network_id=${encodeURIComponent(restNet)}`, { headers: { Authorization: `Bearer ${tok}` } });
|
|
235
|
+
const b = await res.json();
|
|
236
|
+
if (!b.ok) throw new Error("list failed: " + JSON.stringify(b));
|
|
237
|
+
return b.daemons;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test("owner sees default_workdir_root on the new daemon", async () => {
|
|
241
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-new");
|
|
242
|
+
expect(d?.default_workdir_root).toBe(ROOT);
|
|
243
|
+
});
|
|
244
|
+
test("old daemon row has NO default_workdir_root key (app hides the row)", async () => {
|
|
245
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-old");
|
|
246
|
+
expect(d).toBeDefined();
|
|
247
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
test("🔴 member does NOT see the host's home path", async () => {
|
|
250
|
+
const rows = await list(memberTok);
|
|
251
|
+
const d = rows.find(x => x.alias === "wd-new");
|
|
252
|
+
expect(d).toBeDefined();
|
|
253
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|
package/src/create-node.ts
CHANGED
|
@@ -272,13 +272,58 @@ export function resolveCallerDaemonTokenBound(opts: {
|
|
|
272
272
|
// RFC-026 §4.5 — append a row to audit_log for every create_node
|
|
273
273
|
// lifecycle event. Best-effort: never throw out (calling tool should
|
|
274
274
|
// continue even if audit insert fails for any reason).
|
|
275
|
+
/** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
|
|
276
|
+
* but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
|
|
277
|
+
* audit INSERT can ROLLBACK if the audit row would have been lost.
|
|
278
|
+
* Required for the "no audit-but state changed" window that §4.5 / D8
|
|
279
|
+
* explicitly closes (PR1 SF-2 review catch — auditCreateNode's
|
|
280
|
+
* swallow-and-warn defeats atomicity).
|
|
281
|
+
*
|
|
282
|
+
* Callers in a non-transaction context should keep using auditCreateNode
|
|
283
|
+
* (best-effort, never throws). */
|
|
284
|
+
export function auditCreateNodeStrict(input: {
|
|
285
|
+
action: Parameters<typeof auditCreateNode>[0]["action"];
|
|
286
|
+
user_id?: string | null;
|
|
287
|
+
username?: string | null;
|
|
288
|
+
network_id?: string | null;
|
|
289
|
+
target_id?: string | null;
|
|
290
|
+
detail: Record<string, unknown>;
|
|
291
|
+
}): void {
|
|
292
|
+
db.run(
|
|
293
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
294
|
+
VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
|
|
295
|
+
[
|
|
296
|
+
input.user_id || null,
|
|
297
|
+
input.username || null,
|
|
298
|
+
input.action,
|
|
299
|
+
input.target_id || null,
|
|
300
|
+
JSON.stringify(input.detail),
|
|
301
|
+
input.network_id || null,
|
|
302
|
+
],
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
275
306
|
export function auditCreateNode(input: {
|
|
276
307
|
action:
|
|
277
308
|
| "create_node_dispatched"
|
|
309
|
+
| "create_node_dispatch_attempted"
|
|
278
310
|
| "create_node_rejected"
|
|
279
311
|
| "create_node_succeeded"
|
|
280
312
|
| "create_node_sweeper_revoked"
|
|
281
|
-
| "daemon_capability_lied"
|
|
313
|
+
| "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
|
|
314
|
+
// RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
|
|
315
|
+
// Re-using auditCreateNode is fine: the action column carries the
|
|
316
|
+
// discriminator; target_type stays 'node_create_request' which is a
|
|
317
|
+
// misnomer for stop/delete rows but updating the schema would force a
|
|
318
|
+
// wider migration (see P1.1 issue).
|
|
319
|
+
| "stop_node_dispatched"
|
|
320
|
+
| "stop_node_completed"
|
|
321
|
+
| "start_node_dispatched"
|
|
322
|
+
| "start_node_completed"
|
|
323
|
+
| "delete_node_dispatched"
|
|
324
|
+
| "delete_node_completed"
|
|
325
|
+
| "forced_stop_with_in_flight"
|
|
326
|
+
| "backup_purged";
|
|
282
327
|
user_id?: string | null;
|
|
283
328
|
username?: string | null;
|
|
284
329
|
network_id?: string | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { isInteractiveDashboardTask } from "../../agent-node/src/inbox-dispatch";
|
|
6
|
+
import { shouldCreateScheduledGoal } from "../../agent-node/src/goals/routing";
|
|
7
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
8
|
+
import { db } from "./db.js";
|
|
9
|
+
|
|
10
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dashboard-slash-http-"));
|
|
11
|
+
const TARGET = `dashboard-slash-target-${process.pid}`;
|
|
12
|
+
const TARGET_NODE_ID = `node_dashboard_slash_target_${process.pid}`;
|
|
13
|
+
const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
|
|
14
|
+
let server: any;
|
|
15
|
+
let base = "";
|
|
16
|
+
let userToken = "";
|
|
17
|
+
let nodeToken = "";
|
|
18
|
+
let networkId = "";
|
|
19
|
+
let requestSequence = 0;
|
|
20
|
+
|
|
21
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
22
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
23
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
24
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
27
|
+
const username = `dashboard_slash_${Date.now()}`;
|
|
28
|
+
const registered = register(username, "DashboardSlash123!", undefined, "seed");
|
|
29
|
+
expect(registered.ok).toBe(true);
|
|
30
|
+
userToken = registered.token!;
|
|
31
|
+
networkId = registered.network_id!;
|
|
32
|
+
const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
|
|
33
|
+
const minted = createNetworkTokenForNode(userId, networkId, "dashboard-slash-node");
|
|
34
|
+
expect(minted.ok).toBe(true);
|
|
35
|
+
nodeToken = minted.token!;
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
38
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
39
|
+
[TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
|
|
40
|
+
);
|
|
41
|
+
db.run(
|
|
42
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
43
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
44
|
+
[TARGET_NODE_ID, TARGET, networkId],
|
|
45
|
+
);
|
|
46
|
+
const mod: any = await import("./server.js");
|
|
47
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
48
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
49
|
+
}, 30_000);
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
try { server?.stop?.(true); } catch {}
|
|
53
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function postAndRead(token: string, from: string, task: string) {
|
|
57
|
+
requestSequence += 1;
|
|
58
|
+
const clientRequestId = `dreq_${requestSequence.toString(16).padStart(32, "0")}`;
|
|
59
|
+
const response = await fetch(`${base}/api/task`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
alias: TARGET,
|
|
64
|
+
from,
|
|
65
|
+
network_id: networkId,
|
|
66
|
+
task,
|
|
67
|
+
meta: { source: "dashboard-chat", client_request_id: clientRequestId, auth_origin: "user" },
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
const body = await response.json() as any;
|
|
71
|
+
expect([200, 202]).toContain(response.status);
|
|
72
|
+
const taskId = body.task_id ?? body.message_id;
|
|
73
|
+
const row = db.get<{ content: string; meta_json: string }>(
|
|
74
|
+
"SELECT content, meta_json FROM inbox WHERE id = ?1",
|
|
75
|
+
[taskId],
|
|
76
|
+
);
|
|
77
|
+
expect(row).toBeTruthy();
|
|
78
|
+
return { type: "task", content: row!.content, meta_json: row!.meta_json };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("real Hub → authenticated Dashboard slash routing", () => {
|
|
82
|
+
test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
|
|
83
|
+
for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
|
|
84
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
85
|
+
expect(row.content).toBe(command);
|
|
86
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
87
|
+
for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
|
|
88
|
+
expect(shouldCreateScheduledGoal(row.content, runtime, true)).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
|
|
94
|
+
for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
|
|
95
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
96
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
97
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("node token cannot forge Dashboard pass-through and keeps legacy compatibility", async () => {
|
|
102
|
+
const row = await postAndRead(nodeToken, "dashboard-slash-node", "/loop 5m legacy automation");
|
|
103
|
+
expect(JSON.parse(row.meta_json).auth_origin).toBe("node");
|
|
104
|
+
expect(isInteractiveDashboardTask(row)).toBe(false);
|
|
105
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", false)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeTestDatabaseEnv,
|
|
4
|
+
redactPgUrl,
|
|
5
|
+
resolveDatabaseTarget,
|
|
6
|
+
} from "./db-adapter";
|
|
7
|
+
|
|
8
|
+
const BASE_ENV: NodeJS.ProcessEnv = { HOME: "/nonexistent" };
|
|
9
|
+
|
|
10
|
+
describe("#435 inherited DATABASE_URL guard", () => {
|
|
11
|
+
for (const databaseUrl of [
|
|
12
|
+
"postgres://user:pw@prod.example:5432/commhub",
|
|
13
|
+
"postgresql://user:pw@prod.example:5432/commhub",
|
|
14
|
+
"sqlite:///tmp/not-an-escape",
|
|
15
|
+
]) {
|
|
16
|
+
test(`NODE_ENV=test rejects ${databaseUrl.split(":", 1)[0]} DATABASE_URL`, () => {
|
|
17
|
+
expect(() => assertSafeTestDatabaseEnv({
|
|
18
|
+
...BASE_ENV,
|
|
19
|
+
NODE_ENV: "test",
|
|
20
|
+
DATABASE_URL: databaseUrl,
|
|
21
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("test without DATABASE_URL leaves the existing SQLite guard in charge", () => {
|
|
26
|
+
expect(() => assertSafeTestDatabaseEnv({ ...BASE_ENV, NODE_ENV: "test" })).not.toThrow();
|
|
27
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "test" }))
|
|
28
|
+
.toThrow(/REFUSING to open the default SQLite database/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("DATABASE_URL refusal wins even when COMMHUB_DB is also set", () => {
|
|
32
|
+
expect(() => resolveDatabaseTarget({
|
|
33
|
+
...BASE_ENV,
|
|
34
|
+
NODE_ENV: "test",
|
|
35
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
36
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
37
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("production postgres branch remains unchanged without constructing it", () => {
|
|
41
|
+
const url = "postgres://user:pw@prod.example:5432/commhub";
|
|
42
|
+
expect(resolveDatabaseTarget({
|
|
43
|
+
...BASE_ENV,
|
|
44
|
+
NODE_ENV: "production",
|
|
45
|
+
DATABASE_URL: url,
|
|
46
|
+
})).toEqual({ kind: "postgres", url });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("unset NODE_ENV still selects postgres", () => {
|
|
50
|
+
const url = "postgresql://user:pw@prod.example:5432/commhub";
|
|
51
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, DATABASE_URL: url }))
|
|
52
|
+
.toEqual({ kind: "postgres", url });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("explicit SQLite targets remain unchanged in test and production", () => {
|
|
56
|
+
for (const NODE_ENV of ["test", "production"]) {
|
|
57
|
+
expect(resolveDatabaseTarget({
|
|
58
|
+
...BASE_ENV,
|
|
59
|
+
NODE_ENV,
|
|
60
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
61
|
+
})).toEqual({ kind: "sqlite", path: "/tmp/isolated.db" });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("non-server callers cannot silently fall through to the production default SQLite path", () => {
|
|
66
|
+
for (const NODE_ENV of [undefined, "development", "production"]) {
|
|
67
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV }))
|
|
68
|
+
.toThrow(/explicit COMMHUB_DB|COMMHUB_SERVER=1/);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the explicit server boot capability permits the canonical default SQLite path", () => {
|
|
73
|
+
expect(resolveDatabaseTarget({
|
|
74
|
+
...BASE_ENV,
|
|
75
|
+
NODE_ENV: "production",
|
|
76
|
+
COMMHUB_SERVER: "1",
|
|
77
|
+
})).toEqual({ kind: "sqlite", path: "/nonexistent/.commhub/commhub.db" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("lookalike server capability values fail closed", () => {
|
|
81
|
+
for (const COMMHUB_SERVER of ["", "true", "yes", "01", " 1"] ) {
|
|
82
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, COMMHUB_SERVER }))
|
|
83
|
+
.toThrow(/COMMHUB_SERVER=1/);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("RFC-039 §9 COMMHUB_TEST_PG_URL (test-only PostgreSQL)", () => {
|
|
89
|
+
const TEST = { ...BASE_ENV, NODE_ENV: "test" };
|
|
90
|
+
const ok = (url: string) => expect(resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url })).toEqual({ kind: "postgres", url });
|
|
91
|
+
const refused = (url: string, why: RegExp) => {
|
|
92
|
+
let message = "";
|
|
93
|
+
try { resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url }); } catch (e: any) { message = String(e?.message ?? e); }
|
|
94
|
+
expect(message).toMatch(/REFUSING COMMHUB_TEST_PG_URL/);
|
|
95
|
+
expect(message).toMatch(why);
|
|
96
|
+
// Credentials never reach an error message.
|
|
97
|
+
expect(message).not.toContain("s3cret");
|
|
98
|
+
expect(message).not.toContain("tester");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
test("loopback host + anet_*_test database is accepted under NODE_ENV=test", () => {
|
|
102
|
+
ok("postgres://tester:s3cret@127.0.0.1:5432/anet_sched_test");
|
|
103
|
+
ok("postgresql://tester:s3cret@localhost/anet_side_thread_test");
|
|
104
|
+
ok("postgres://tester:s3cret@[::1]:6543/anet_x_test?connect_timeout=2&sslmode=disable");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("ignored outside NODE_ENV=test", () => {
|
|
108
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "production", COMMHUB_DB: "/tmp/i.db", COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test" }))
|
|
109
|
+
.toEqual({ kind: "sqlite", path: "/tmp/i.db" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("inherited DATABASE_URL still refuses even when COMMHUB_TEST_PG_URL is set", () => {
|
|
113
|
+
expect(() => resolveDatabaseTarget({
|
|
114
|
+
...TEST,
|
|
115
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
116
|
+
COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test",
|
|
117
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refuses a value that is not a URL or not postgres", () => {
|
|
121
|
+
refused("not a url", /not a URL/);
|
|
122
|
+
refused("mysql://tester:s3cret@127.0.0.1/anet_x_test", /scheme/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses every host that is not literally loopback", () => {
|
|
126
|
+
for (const host of ["10.0.0.5", "db.internal", "localhost.evil.example", "127.0.0.2", "127.0.0.1,evil.example", "LOCALHOST", "%2Ftmp"]) {
|
|
127
|
+
refused(`postgres://tester:s3cret@${host}/anet_x_test`, /host must be/);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("refuses database names outside /^anet_[a-z0-9_]*_test$/", () => {
|
|
132
|
+
for (const name of ["commhub", "anet_x", "anet_x_test2", "ANET_X_TEST", "anet-x_test", "anet_x_test/extra", ""]) {
|
|
133
|
+
refused(`postgres://tester:s3cret@127.0.0.1/${name}`, /database name/);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("refuses connection-steering query parameters", () => {
|
|
138
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?host=db.internal", /"host" is not allowed/);
|
|
139
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?options=-csearch_path%3Dprod", /"options" is not allowed/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("redactPgUrl drops user and password", () => {
|
|
143
|
+
const out = redactPgUrl("postgres://tester:s3cret@127.0.0.1:5432/anet_x_test");
|
|
144
|
+
expect(out).not.toContain("tester");
|
|
145
|
+
expect(out).not.toContain("s3cret");
|
|
146
|
+
expect(out).toContain("127.0.0.1:5432/anet_x_test");
|
|
147
|
+
});
|
|
148
|
+
});
|