@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1
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/package.json +2 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// SEC-1 cross-tenant write防护带 for RFC-024 config-apply.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors src/cross-tenant-injection.test.ts pattern from PR #275 — drives
|
|
4
|
+
// the SQL queries the tool handlers run, against a real in-process SQLite,
|
|
5
|
+
// to prove the network-scope guard rejects cross-tenant writes against
|
|
6
|
+
// persisted rows. Pure SQL-level regression guard; the MCP tool-handler
|
|
7
|
+
// layer is the same logic wrapped in JSON-RPC.
|
|
8
|
+
//
|
|
9
|
+
// What's gated here:
|
|
10
|
+
// - update_node_config / restart_node: hub-side SELECT node.network_id
|
|
11
|
+
// check before INSERT into node_config_updates
|
|
12
|
+
// - get_config_update: caller-alias-to-node-network match
|
|
13
|
+
// - ack_config_update: same match on ack path
|
|
14
|
+
//
|
|
15
|
+
// Run with: COMMHUB_DB=/tmp/sec1-test.db bun test src/config-apply-sec1.test.ts
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
18
|
+
import { db, uuidv4 } from "./db.js";
|
|
19
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
20
|
+
|
|
21
|
+
function insertNode(opts: { node_id?: string; alias: string; network_id: string | null }): string {
|
|
22
|
+
const id = opts.node_id ?? uuidv4();
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_revision) VALUES (?1, ?2, ?3, ?4, 0)`,
|
|
25
|
+
[id, opts.alias, opts.alias, opts.network_id],
|
|
26
|
+
);
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function insertUpdate(opts: {
|
|
31
|
+
update_id?: string;
|
|
32
|
+
node_id: string;
|
|
33
|
+
network_id: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
patch?: any;
|
|
36
|
+
}): string {
|
|
37
|
+
const id = opts.update_id ?? `cu_${uuidv4()}`;
|
|
38
|
+
db.run(
|
|
39
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, 'hot', 0, ?5, ?6, 'test')`,
|
|
40
|
+
[id, opts.node_id, opts.network_id, JSON.stringify(opts.patch ?? {}), opts.status ?? "pending", Date.now()],
|
|
41
|
+
);
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
db.run("DELETE FROM node_config_updates");
|
|
47
|
+
db.run("DELETE FROM nodes");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("SEC-1 — resolveTargetNode-equivalent SELECT respects network_id", () => {
|
|
51
|
+
// This is the exact query update_node_config / restart_node run
|
|
52
|
+
// to fetch the target node + check network match. Pinning the SQL
|
|
53
|
+
// behaviour so a future "convenience" change can't drop the network
|
|
54
|
+
// scope (the way the pre-#275 inferred-parent SELECT did).
|
|
55
|
+
|
|
56
|
+
test("node in netA: SELECT by node_id returns network_id=netA", () => {
|
|
57
|
+
const nid = insertNode({ alias: "test-node-A", network_id: "netA" });
|
|
58
|
+
const row = db.get<any>(
|
|
59
|
+
"SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
|
|
60
|
+
nid,
|
|
61
|
+
);
|
|
62
|
+
expect(row?.network_id).toBe("netA");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("netB caller checking a netA node: handler decides cross_network_node", () => {
|
|
66
|
+
const nid = insertNode({ alias: "node-A-only", network_id: "netA" });
|
|
67
|
+
const row = db.get<any>(
|
|
68
|
+
"SELECT node_id, network_id FROM nodes WHERE node_id = ?1",
|
|
69
|
+
nid,
|
|
70
|
+
);
|
|
71
|
+
const callerNet = "netB";
|
|
72
|
+
const nodeNet = row.network_id || "default";
|
|
73
|
+
const sec1Ok = nodeNet === (callerNet || "default");
|
|
74
|
+
expect(sec1Ok).toBe(false); // SEC-1 fires, cross-tenant write would reject
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("same-network caller (netA → netA node): SEC-1 passes", () => {
|
|
78
|
+
const nid = insertNode({ alias: "node-A", network_id: "netA" });
|
|
79
|
+
const row = db.get<any>("SELECT network_id FROM nodes WHERE node_id = ?1", nid);
|
|
80
|
+
const callerNet = "netA";
|
|
81
|
+
const sec1Ok = (row.network_id || "default") === (callerNet || "default");
|
|
82
|
+
expect(sec1Ok).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("default-network caller cannot touch named-network node", () => {
|
|
86
|
+
const nid = insertNode({ alias: "node-net1", network_id: "net1" });
|
|
87
|
+
const row = db.get<any>("SELECT network_id FROM nodes WHERE node_id = ?1", nid);
|
|
88
|
+
const callerNet = null; // default
|
|
89
|
+
const sec1Ok = (row.network_id || "default") === (callerNet || "default");
|
|
90
|
+
expect(sec1Ok).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("named-network caller cannot touch default-network node", () => {
|
|
94
|
+
const nid = insertNode({ alias: "node-default", network_id: null });
|
|
95
|
+
const row = db.get<any>("SELECT network_id FROM nodes WHERE node_id = ?1", nid);
|
|
96
|
+
const callerNet = "netZ";
|
|
97
|
+
const sec1Ok = (row.network_id || "default") === (callerNet || "default");
|
|
98
|
+
expect(sec1Ok).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("SEC-1 — get_config_update / ack_config_update by-alias filter respects network_id", () => {
|
|
103
|
+
// The node-pull path resolves the node by alias + network. Pinning
|
|
104
|
+
// that the WHERE clause excludes other networks' nodes even if the
|
|
105
|
+
// alias happens to collide (rare but possible — nodes table allows
|
|
106
|
+
// same alias across networks per the V3 schema).
|
|
107
|
+
|
|
108
|
+
test("two nodes with same alias in two networks → query with network_id returns only the matching one", () => {
|
|
109
|
+
insertNode({ alias: "same-alias", network_id: "netA" });
|
|
110
|
+
insertNode({ alias: "same-alias", network_id: "netB" });
|
|
111
|
+
const rowA = db.get<any>(
|
|
112
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
113
|
+
"same-alias", "netA",
|
|
114
|
+
);
|
|
115
|
+
const rowB = db.get<any>(
|
|
116
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
117
|
+
"same-alias", "netB",
|
|
118
|
+
);
|
|
119
|
+
expect(rowA?.node_id).not.toBe(rowB?.node_id);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("node-pull get_config_update: netA node ntok_ cannot fetch netB node's pending update", () => {
|
|
123
|
+
const nodeA = insertNode({ alias: "puller-A", network_id: "netA" });
|
|
124
|
+
const nodeB = insertNode({ alias: "puller-B", network_id: "netB" });
|
|
125
|
+
insertUpdate({ node_id: nodeB, network_id: "netB", status: "pending", patch: { flags: { maxTurns: 100 } } });
|
|
126
|
+
// Simulate: ntok_ resolved to callerAlias="puller-A", enforceNetworkId="netA"
|
|
127
|
+
// get_config_update SELECTs node by (alias, network_id), then SELECTs update by node.node_id
|
|
128
|
+
const callerNode = db.get<any>(
|
|
129
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
130
|
+
"puller-A", "netA",
|
|
131
|
+
);
|
|
132
|
+
expect(callerNode?.node_id).toBe(nodeA);
|
|
133
|
+
const update = db.get<any>(
|
|
134
|
+
"SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1",
|
|
135
|
+
callerNode.node_id,
|
|
136
|
+
);
|
|
137
|
+
// No update for nodeA (the one in netB is not visible)
|
|
138
|
+
expect(update == null).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("ack_config_update: netA node cannot ack netB node's update", () => {
|
|
142
|
+
const nodeA = insertNode({ alias: "acker-A", network_id: "netA" });
|
|
143
|
+
const nodeB = insertNode({ alias: "acker-B", network_id: "netB" });
|
|
144
|
+
const updId = insertUpdate({ node_id: nodeB, network_id: "netB", status: "pending" });
|
|
145
|
+
// Simulate netA node trying to ack updId. Handler resolves caller alias
|
|
146
|
+
// -> node row by (alias, network_id), then verifies update.node_id matches.
|
|
147
|
+
const callerNode = db.get<any>(
|
|
148
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
149
|
+
"acker-A", "netA",
|
|
150
|
+
);
|
|
151
|
+
expect(callerNode?.node_id).toBe(nodeA);
|
|
152
|
+
const update = db.get<any>(
|
|
153
|
+
"SELECT update_id, node_id FROM node_config_updates WHERE update_id = ?1",
|
|
154
|
+
updId,
|
|
155
|
+
);
|
|
156
|
+
const ownershipMatch = update?.node_id === callerNode?.node_id;
|
|
157
|
+
expect(ownershipMatch).toBe(false); // would surface as ignored "unknown_or_foreign_update"
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("ack: same-node ack passes ownership check", () => {
|
|
161
|
+
const nid = insertNode({ alias: "self-ack", network_id: "netA" });
|
|
162
|
+
const updId = insertUpdate({ node_id: nid, network_id: "netA", status: "pending" });
|
|
163
|
+
const callerNode = db.get<any>(
|
|
164
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
165
|
+
"self-ack", "netA",
|
|
166
|
+
);
|
|
167
|
+
const update = db.get<any>(
|
|
168
|
+
"SELECT update_id, node_id FROM node_config_updates WHERE update_id = ?1",
|
|
169
|
+
updId,
|
|
170
|
+
);
|
|
171
|
+
expect(update?.node_id).toBe(callerNode?.node_id);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("F-B (stale-update reaper) — single-flight TTL prevents permanent lock-out", () => {
|
|
176
|
+
// Pinning the stale-supersede semantics: an in-flight row older than
|
|
177
|
+
// 60_000 ms (2× apply ceiling) is treated as stale and a new update
|
|
178
|
+
// can supersede it (marks old as timeout). Without this, a crashed
|
|
179
|
+
// node ack'd "restarting" would brick remote restart forever.
|
|
180
|
+
|
|
181
|
+
test("fresh in-flight (< 60s old) → blocks new update (single-flight)", () => {
|
|
182
|
+
const nid = insertNode({ alias: "fresh", network_id: "netA" });
|
|
183
|
+
// Insert with a created_at recent (< 60s).
|
|
184
|
+
const fresh = `cu_${uuidv4()}`;
|
|
185
|
+
db.run(
|
|
186
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test')`,
|
|
187
|
+
[fresh, nid, "netA", Date.now() - 10_000],
|
|
188
|
+
);
|
|
189
|
+
const STALE = 60_000;
|
|
190
|
+
const row = db.get<any>(
|
|
191
|
+
"SELECT update_id, created_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
192
|
+
nid,
|
|
193
|
+
);
|
|
194
|
+
const age = Date.now() - row.created_at;
|
|
195
|
+
expect(age).toBeLessThan(STALE);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("stale in-flight (> 60s old) → can be superseded (marked timeout)", () => {
|
|
199
|
+
const nid = insertNode({ alias: "stale", network_id: "netA" });
|
|
200
|
+
const stale = `cu_${uuidv4()}`;
|
|
201
|
+
db.run(
|
|
202
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test')`,
|
|
203
|
+
[stale, nid, "netA", Date.now() - 120_000], // 2 min ago
|
|
204
|
+
);
|
|
205
|
+
const STALE = 60_000;
|
|
206
|
+
const row = db.get<any>(
|
|
207
|
+
"SELECT update_id, created_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
208
|
+
nid,
|
|
209
|
+
);
|
|
210
|
+
const age = Date.now() - row.created_at;
|
|
211
|
+
expect(age).toBeGreaterThan(STALE);
|
|
212
|
+
// Simulate the supersede operation done by the tool handler.
|
|
213
|
+
db.run(
|
|
214
|
+
"UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = 'superseded' WHERE update_id = ?2",
|
|
215
|
+
[Date.now(), stale],
|
|
216
|
+
);
|
|
217
|
+
// After supersede, no in-flight remains.
|
|
218
|
+
const remaining = db.get<any>(
|
|
219
|
+
"SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')",
|
|
220
|
+
nid,
|
|
221
|
+
);
|
|
222
|
+
expect(remaining == null).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("SEC trust-root — report_status node upsert cannot re-home cross-tenant (#287 通信牛 catch)", () => {
|
|
227
|
+
// 通信牛 catch on #287: report_status is a heartbeat called by every
|
|
228
|
+
// ntok_'d node, and it upserts the nodes table with caller-supplied
|
|
229
|
+
// node_id + caller's network_id. The old `ON CONFLICT DO UPDATE` blob
|
|
230
|
+
// unconditionally set `network_id = COALESCE(?, nodes.network_id)`,
|
|
231
|
+
// which would re-home an existing row if the caller's network differed.
|
|
232
|
+
// Since resolveTargetNode (in config-apply tools) reads nodes.network_id
|
|
233
|
+
// to authorize writes, that re-home would let a foreign network become
|
|
234
|
+
// "authorized" to flip the row's config — defeating the cross-tenant
|
|
235
|
+
// 防护带 from PR #275.
|
|
236
|
+
//
|
|
237
|
+
// Fix: SELECT-then-decide. If existing.network_id is set AND differs
|
|
238
|
+
// from caller's effectiveNetId, skip the upsert entirely. These tests
|
|
239
|
+
// pin the SELECT semantics at the SQL layer.
|
|
240
|
+
|
|
241
|
+
test("netA caller checking a netB-owned node_id: SEC-1 verdict = NOT-OK (refuse upsert)", () => {
|
|
242
|
+
const nid = insertNode({ alias: "victim-net-b", network_id: "netB" });
|
|
243
|
+
const callerNet = "netA";
|
|
244
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
245
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
246
|
+
nid,
|
|
247
|
+
);
|
|
248
|
+
const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
249
|
+
const sec1Ok = !existing
|
|
250
|
+
|| existing.network_id === null
|
|
251
|
+
|| existing.network_id === undefined
|
|
252
|
+
|| norm(existing.network_id) === norm(callerNet);
|
|
253
|
+
expect(sec1Ok).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("netB caller on its own node_id: SEC-1 verdict = OK", () => {
|
|
257
|
+
const nid = insertNode({ alias: "owner-net-b", network_id: "netB" });
|
|
258
|
+
const callerNet = "netB";
|
|
259
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
260
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
261
|
+
nid,
|
|
262
|
+
);
|
|
263
|
+
const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
264
|
+
const sec1Ok = norm(existing?.network_id) === norm(callerNet);
|
|
265
|
+
expect(sec1Ok).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("legacy row (network_id NULL) — first network to claim it wins (bootstrap)", () => {
|
|
269
|
+
const nid = insertNode({ alias: "legacy", network_id: null });
|
|
270
|
+
const callerNet = "netA";
|
|
271
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
272
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
273
|
+
nid,
|
|
274
|
+
);
|
|
275
|
+
// Legacy row with NULL → caller's report_status would set it to netA.
|
|
276
|
+
const sec1Ok = !existing
|
|
277
|
+
|| existing.network_id === null
|
|
278
|
+
|| existing.network_id === undefined;
|
|
279
|
+
expect(sec1Ok).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("default-network caller against named-network node_id → refused (no implicit promotion)", () => {
|
|
283
|
+
const nid = insertNode({ alias: "named-only", network_id: "netA" });
|
|
284
|
+
const callerNet: string | null = null; // default
|
|
285
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
286
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
287
|
+
nid,
|
|
288
|
+
);
|
|
289
|
+
const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
290
|
+
const sec1Ok = !existing
|
|
291
|
+
|| existing.network_id === null
|
|
292
|
+
|| norm(existing.network_id) === norm(callerNet);
|
|
293
|
+
expect(sec1Ok).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("named-network caller against default-network node_id → refused", () => {
|
|
297
|
+
const nid = insertNode({ alias: "default-only", network_id: null });
|
|
298
|
+
// After bootstrap (caller netA claimed), set the row to a third
|
|
299
|
+
// network so this test isolates the refuse path.
|
|
300
|
+
db.run("UPDATE nodes SET network_id = ?1 WHERE node_id = ?2", ["netZ", nid]);
|
|
301
|
+
const callerNet = "netA";
|
|
302
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
303
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
304
|
+
nid,
|
|
305
|
+
);
|
|
306
|
+
const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
307
|
+
const sec1Ok = norm(existing?.network_id) === norm(callerNet);
|
|
308
|
+
expect(sec1Ok).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("after cross-tenant attempt is refused, victim row's network_id is unchanged", () => {
|
|
312
|
+
const nid = insertNode({ alias: "stable-net-b", network_id: "netB" });
|
|
313
|
+
const callerNet = "netA";
|
|
314
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
315
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
316
|
+
nid,
|
|
317
|
+
);
|
|
318
|
+
const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
319
|
+
const sec1Ok = norm(existing?.network_id) === norm(callerNet);
|
|
320
|
+
expect(sec1Ok).toBe(false);
|
|
321
|
+
// Re-read to confirm no mutation happened (we never wrote in this
|
|
322
|
+
// helper; the production guard would have refused the UPDATE).
|
|
323
|
+
const after = db.get<{ network_id: string | null }>(
|
|
324
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
325
|
+
nid,
|
|
326
|
+
);
|
|
327
|
+
expect(after?.network_id).toBe("netB");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
332
|
+
// Real-driven regression test (per 通信龙 test-quality finding 2026-06-28):
|
|
333
|
+
// the 6 SEC trust-root tests above mirror the gate inline (cheap to write,
|
|
334
|
+
// zero protection against guard drift). This block calls the SAME helper
|
|
335
|
+
// that production report_status delegates to — if anyone weakens or
|
|
336
|
+
// deletes the gate, this fails.
|
|
337
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
338
|
+
describe("SEC trust-root — REAL driver via upsertNodeWithSec1Guard (catches guard drift)", () => {
|
|
339
|
+
test("netA caller cannot mutate netB-owned node row (production path)", () => {
|
|
340
|
+
const nid = insertNode({ alias: "victim", network_id: "netB" });
|
|
341
|
+
// Pre-state: row owned by netB.
|
|
342
|
+
const pre = db.get<any>(
|
|
343
|
+
"SELECT alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
344
|
+
nid,
|
|
345
|
+
);
|
|
346
|
+
expect(pre.network_id).toBe("netB");
|
|
347
|
+
expect(pre.alias).toBe("victim");
|
|
348
|
+
expect(pre.config_snapshot).toBeNull();
|
|
349
|
+
|
|
350
|
+
// Attack: netA caller (effectiveNetId="netA") tries to upsert with
|
|
351
|
+
// a different alias + a forged config snapshot.
|
|
352
|
+
const outcome = upsertNodeWithSec1Guard({
|
|
353
|
+
node_id: nid,
|
|
354
|
+
callerNetworkId: "netA",
|
|
355
|
+
alias: "attacker-alias",
|
|
356
|
+
model: "attacker-model",
|
|
357
|
+
config_snapshot: { model: "attacker", flags: { dangerouslySkipPermissions: true } },
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Production path returns refused (NOT inserted/updated).
|
|
361
|
+
expect(outcome.result).toBe("refused");
|
|
362
|
+
if (outcome.result === "refused") {
|
|
363
|
+
expect(outcome.reason).toBe("cross_network");
|
|
364
|
+
expect(outcome.existingNet).toBe("netB");
|
|
365
|
+
expect(outcome.callerNet).toBe("netA");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// End-state: row IS UNCHANGED — same alias, same network, no
|
|
369
|
+
// forged snapshot leaked in. If the gate were removed, alias would
|
|
370
|
+
// be "attacker-alias", model would be "attacker-model", and
|
|
371
|
+
// config_snapshot would be the forged payload. This is the
|
|
372
|
+
// assertion that drift would break.
|
|
373
|
+
const post = db.get<any>(
|
|
374
|
+
"SELECT alias, network_id, model, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
375
|
+
nid,
|
|
376
|
+
);
|
|
377
|
+
expect(post.network_id).toBe("netB");
|
|
378
|
+
expect(post.alias).toBe("victim");
|
|
379
|
+
expect(post.model).toBeNull();
|
|
380
|
+
expect(post.config_snapshot).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("netB caller updating its own node row → production path returns updated", () => {
|
|
384
|
+
const nid = insertNode({ alias: "owner-self", network_id: "netB" });
|
|
385
|
+
const outcome = upsertNodeWithSec1Guard({
|
|
386
|
+
node_id: nid,
|
|
387
|
+
callerNetworkId: "netB",
|
|
388
|
+
alias: "owner-self",
|
|
389
|
+
model: "new-model",
|
|
390
|
+
config_snapshot: { model: "new-model", flags: { maxTurns: 99 } },
|
|
391
|
+
});
|
|
392
|
+
expect(outcome.result).toBe("updated");
|
|
393
|
+
const post = db.get<any>(
|
|
394
|
+
"SELECT alias, network_id, model, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
395
|
+
nid,
|
|
396
|
+
);
|
|
397
|
+
expect(post.network_id).toBe("netB");
|
|
398
|
+
expect(post.model).toBe("new-model");
|
|
399
|
+
expect(post.config_snapshot).toContain("maxTurns");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("legacy NULL row → first caller claims (bootstrap, production path)", () => {
|
|
403
|
+
const nid = insertNode({ alias: "legacy-row", network_id: null });
|
|
404
|
+
const outcome = upsertNodeWithSec1Guard({
|
|
405
|
+
node_id: nid,
|
|
406
|
+
callerNetworkId: "netA",
|
|
407
|
+
alias: "legacy-row",
|
|
408
|
+
});
|
|
409
|
+
// sec1Ok is true (existing.network_id === null) → updated.
|
|
410
|
+
expect(outcome.result).toBe("updated");
|
|
411
|
+
const post = db.get<{ network_id: string | null }>(
|
|
412
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
413
|
+
nid,
|
|
414
|
+
);
|
|
415
|
+
expect(post?.network_id).toBe("netA");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("first write of a new node_id → inserted (callerNet becomes owner)", () => {
|
|
419
|
+
const nid = `node_${uuidv4()}`;
|
|
420
|
+
const outcome = upsertNodeWithSec1Guard({
|
|
421
|
+
node_id: nid,
|
|
422
|
+
callerNetworkId: "netC",
|
|
423
|
+
alias: "freshman",
|
|
424
|
+
});
|
|
425
|
+
expect(outcome.result).toBe("inserted");
|
|
426
|
+
const post = db.get<any>(
|
|
427
|
+
"SELECT network_id, alias FROM nodes WHERE node_id = ?1",
|
|
428
|
+
nid,
|
|
429
|
+
);
|
|
430
|
+
expect(post.network_id).toBe("netC");
|
|
431
|
+
expect(post.alias).toBe("freshman");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("default-network caller against named-network node → refused (no implicit promotion)", () => {
|
|
435
|
+
const nid = insertNode({ alias: "named-only", network_id: "netA" });
|
|
436
|
+
const outcome = upsertNodeWithSec1Guard({
|
|
437
|
+
node_id: nid,
|
|
438
|
+
callerNetworkId: null, // default
|
|
439
|
+
alias: "implicit-promote-attempt",
|
|
440
|
+
});
|
|
441
|
+
expect(outcome.result).toBe("refused");
|
|
442
|
+
const post = db.get<any>("SELECT network_id, alias FROM nodes WHERE node_id = ?1", nid);
|
|
443
|
+
expect(post.network_id).toBe("netA");
|
|
444
|
+
expect(post.alias).toBe("named-only");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("F-B polish — reaper anchors on COALESCE(acked_at, created_at), not created_at", () => {
|
|
449
|
+
// 通信龙 catch: reaper threshold = 60s and drain hard-cap = 60s overlap.
|
|
450
|
+
// A healthy-but-slow restart (drain 60s + respawn time) could exceed
|
|
451
|
+
// the threshold if anchored on created_at alone. Anchoring on acked_at
|
|
452
|
+
// means "node ack'd restarting → liveness clock refreshes" so an
|
|
453
|
+
// in-progress restart isn't falsely reaped.
|
|
454
|
+
|
|
455
|
+
test("row with old created_at + recent acked_at → fresh (age from acked_at)", () => {
|
|
456
|
+
const nid = insertNode({ alias: "slow-restart", network_id: "netA" });
|
|
457
|
+
const uid = `cu_${uuidv4()}`;
|
|
458
|
+
const ancientCreatedAt = Date.now() - 120_000; // 2 min ago
|
|
459
|
+
const recentAckedAt = Date.now() - 30_000; // 30s ago
|
|
460
|
+
db.run(
|
|
461
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token, acked_at) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test', ?5)`,
|
|
462
|
+
[uid, nid, "netA", ancientCreatedAt, recentAckedAt],
|
|
463
|
+
);
|
|
464
|
+
const STALE = 60_000;
|
|
465
|
+
const row = db.get<any>(
|
|
466
|
+
"SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
467
|
+
nid,
|
|
468
|
+
);
|
|
469
|
+
const anchor = row.acked_at ?? row.created_at;
|
|
470
|
+
const age = Date.now() - anchor;
|
|
471
|
+
// age is from acked_at (~30s), NOT from created_at (~120s).
|
|
472
|
+
expect(age).toBeLessThan(STALE);
|
|
473
|
+
expect(age).toBeGreaterThan(20_000); // sanity: roughly 30s
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("row with no acked_at + old created_at → stale (falls back to created_at)", () => {
|
|
477
|
+
const nid = insertNode({ alias: "never-acked", network_id: "netA" });
|
|
478
|
+
const uid = `cu_${uuidv4()}`;
|
|
479
|
+
db.run(
|
|
480
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'pending', ?4, 'test')`,
|
|
481
|
+
[uid, nid, "netA", Date.now() - 120_000],
|
|
482
|
+
);
|
|
483
|
+
const STALE = 60_000;
|
|
484
|
+
const row = db.get<any>(
|
|
485
|
+
"SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
486
|
+
nid,
|
|
487
|
+
);
|
|
488
|
+
const anchor = row.acked_at ?? row.created_at;
|
|
489
|
+
const age = Date.now() - anchor;
|
|
490
|
+
expect(age).toBeGreaterThan(STALE); // no ack → falls back to created_at
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("row with old acked_at (node ack'd then crashed) → stale", () => {
|
|
494
|
+
const nid = insertNode({ alias: "crashed-after-ack", network_id: "netA" });
|
|
495
|
+
const uid = `cu_${uuidv4()}`;
|
|
496
|
+
db.run(
|
|
497
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token, acked_at) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test', ?5)`,
|
|
498
|
+
[uid, nid, "netA", Date.now() - 200_000, Date.now() - 90_000],
|
|
499
|
+
);
|
|
500
|
+
const STALE = 60_000;
|
|
501
|
+
const row = db.get<any>(
|
|
502
|
+
"SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
503
|
+
nid,
|
|
504
|
+
);
|
|
505
|
+
const anchor = row.acked_at ?? row.created_at;
|
|
506
|
+
const age = Date.now() - anchor;
|
|
507
|
+
expect(age).toBeGreaterThan(STALE); // ack'd 90s ago > 60s threshold
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe("F-C (partial unique index) — DB-layer single-flight regardless of process count", () => {
|
|
512
|
+
test("INSERT a second pending row for same node fails with UNIQUE constraint", () => {
|
|
513
|
+
const nid = insertNode({ alias: "double", network_id: "netA" });
|
|
514
|
+
insertUpdate({ node_id: nid, network_id: "netA", status: "pending" });
|
|
515
|
+
let err: any = null;
|
|
516
|
+
try {
|
|
517
|
+
insertUpdate({ node_id: nid, network_id: "netA", status: "pending" });
|
|
518
|
+
} catch (e: any) { err = e; }
|
|
519
|
+
expect(err).not.toBeNull();
|
|
520
|
+
expect(String(err?.message || "")).toMatch(/UNIQUE|constraint/i);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("INSERT a new pending after a TERMINAL one succeeds (partial index excludes terminal)", () => {
|
|
524
|
+
const nid = insertNode({ alias: "after-applied", network_id: "netA" });
|
|
525
|
+
insertUpdate({ node_id: nid, network_id: "netA", status: "applied" });
|
|
526
|
+
// Should succeed — terminal rows don't occupy the partial index slot.
|
|
527
|
+
expect(() => insertUpdate({ node_id: nid, network_id: "netA", status: "pending" })).not.toThrow();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("SEC-1 — update lifecycle: single-flight + cross-network pending lookup", () => {
|
|
532
|
+
test("netA pending update + netA query for same-node in-flight → finds it", () => {
|
|
533
|
+
const nid = insertNode({ alias: "n1", network_id: "netA" });
|
|
534
|
+
const uid = insertUpdate({ node_id: nid, network_id: "netA", status: "pending" });
|
|
535
|
+
const inFlight = db.get<any>(
|
|
536
|
+
"SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')",
|
|
537
|
+
nid,
|
|
538
|
+
);
|
|
539
|
+
expect(inFlight?.update_id).toBe(uid);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("two updates same node — terminal one does NOT block new one (single-flight only on non-terminal)", () => {
|
|
543
|
+
const nid = insertNode({ alias: "n2", network_id: "netA" });
|
|
544
|
+
insertUpdate({ node_id: nid, network_id: "netA", status: "applied" });
|
|
545
|
+
const inFlight = db.get<any>(
|
|
546
|
+
"SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')",
|
|
547
|
+
nid,
|
|
548
|
+
);
|
|
549
|
+
expect(inFlight == null).toBe(true); // no in-flight, write would be allowed
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("restarting status counts as in-flight (single-flight gate)", () => {
|
|
553
|
+
const nid = insertNode({ alias: "n3", network_id: "netA" });
|
|
554
|
+
insertUpdate({ node_id: nid, network_id: "netA", status: "restarting" });
|
|
555
|
+
const inFlight = db.get<any>(
|
|
556
|
+
"SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')",
|
|
557
|
+
nid,
|
|
558
|
+
);
|
|
559
|
+
expect(inFlight).toBeDefined();
|
|
560
|
+
});
|
|
561
|
+
});
|