@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,89 @@
|
|
|
1
|
+
/** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
|
|
2
|
+
export function normalizeTags(value: unknown): string[] | null {
|
|
3
|
+
if (!Array.isArray(value) || value.length > 10) return null;
|
|
4
|
+
const tags: string[] = [];
|
|
5
|
+
for (const valueTag of value) {
|
|
6
|
+
if (typeof valueTag !== "string") return null;
|
|
7
|
+
const tag = valueTag.trim();
|
|
8
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
|
|
9
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
10
|
+
}
|
|
11
|
+
return tags;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function storedTags(value: string | null): string[] {
|
|
15
|
+
try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
|
|
19
|
+
// 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
|
|
20
|
+
// 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
|
|
21
|
+
export const MAX_MERGE_SOURCES = 50;
|
|
22
|
+
export type TagOp =
|
|
23
|
+
| { op: "rename"; from: string; to: string }
|
|
24
|
+
| { op: "merge"; from: string[]; to: string }
|
|
25
|
+
| { op: "delete"; tag: string }
|
|
26
|
+
| { op: "color"; tag: string; color: string | null };
|
|
27
|
+
|
|
28
|
+
/** 单个标签名,规则同 normalizeTags。不合法 = null。 */
|
|
29
|
+
export function tagName(value: unknown): string | null {
|
|
30
|
+
const tags = normalizeTags([value]);
|
|
31
|
+
return tags && tags.length === 1 ? tags[0] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
35
|
+
/** 请求体 → 操作;不合法返回错误码。 */
|
|
36
|
+
export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
|
|
37
|
+
switch (body.op) {
|
|
38
|
+
case "rename": {
|
|
39
|
+
const from = tagName(body.from), to = tagName(body.to);
|
|
40
|
+
if (!from || !to) return { error: "invalid_tag" };
|
|
41
|
+
if (from === to) return { error: "same_tag" };
|
|
42
|
+
return { op: "rename", from, to };
|
|
43
|
+
}
|
|
44
|
+
case "merge": {
|
|
45
|
+
if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
|
|
46
|
+
const from: string[] = [];
|
|
47
|
+
for (const value of body.from) {
|
|
48
|
+
const tag = tagName(value);
|
|
49
|
+
if (!tag) return { error: "invalid_tag" };
|
|
50
|
+
if (!from.includes(tag)) from.push(tag);
|
|
51
|
+
}
|
|
52
|
+
const to = tagName(body.to);
|
|
53
|
+
if (!to) return { error: "invalid_tag" };
|
|
54
|
+
// 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
|
|
55
|
+
const sources = from.filter(tag => tag !== to);
|
|
56
|
+
if (!sources.length) return { error: "same_tag" };
|
|
57
|
+
return { op: "merge", from: sources, to };
|
|
58
|
+
}
|
|
59
|
+
case "delete": {
|
|
60
|
+
const tag = tagName(body.tag);
|
|
61
|
+
return tag ? { op: "delete", tag } : { error: "invalid_tag" };
|
|
62
|
+
}
|
|
63
|
+
case "color": {
|
|
64
|
+
const tag = tagName(body.tag);
|
|
65
|
+
if (!tag) return { error: "invalid_tag" };
|
|
66
|
+
if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
|
|
67
|
+
return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return { error: "invalid_tag_op" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
|
|
75
|
+
export function applyTagOp(tags: string[], op: TagOp): string[] | null {
|
|
76
|
+
let sources: string[];
|
|
77
|
+
let to: string | null;
|
|
78
|
+
if (op.op === "rename") { sources = [op.from]; to = op.to; }
|
|
79
|
+
else if (op.op === "merge") { sources = op.from; to = op.to; }
|
|
80
|
+
else if (op.op === "delete") { sources = [op.tag]; to = null; }
|
|
81
|
+
else return null;
|
|
82
|
+
if (!tags.some(tag => sources.includes(tag))) return null;
|
|
83
|
+
const next: string[] = [];
|
|
84
|
+
for (const tag of tags) {
|
|
85
|
+
const mapped = sources.includes(tag) ? to : tag;
|
|
86
|
+
if (mapped !== null && !next.includes(mapped)) next.push(mapped);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
|
|
2
|
+
// 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
|
|
3
|
+
// 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
|
|
4
|
+
import { afterAll, expect, test } from "bun:test";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-req-completed-at-"));
|
|
11
|
+
const file = join(dir, "hub.db");
|
|
12
|
+
process.env.COMMHUB_DB = file;
|
|
13
|
+
|
|
14
|
+
const legacy = new Database(file);
|
|
15
|
+
legacy.exec(`CREATE TABLE requirements (
|
|
16
|
+
requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
17
|
+
column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
|
|
18
|
+
client_id TEXT, issues_json TEXT, created_by TEXT,
|
|
19
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
20
|
+
owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
|
|
21
|
+
const seed: [string, string, string, string][] = [
|
|
22
|
+
// id, column, created_at, updated_at
|
|
23
|
+
["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
|
|
24
|
+
["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
|
|
25
|
+
["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
|
|
26
|
+
["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
|
|
27
|
+
["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
|
|
28
|
+
];
|
|
29
|
+
for (const [id, column, createdAt, updatedAt] of seed) {
|
|
30
|
+
legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
|
|
31
|
+
[id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
|
|
32
|
+
}
|
|
33
|
+
legacy.close();
|
|
34
|
+
|
|
35
|
+
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
36
|
+
|
|
37
|
+
const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
|
|
38
|
+
|
|
39
|
+
test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
|
|
40
|
+
const { db } = await import("./db.js");
|
|
41
|
+
await import("./requirements.js");
|
|
42
|
+
const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
|
|
43
|
+
const by = read(db);
|
|
44
|
+
expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
|
|
45
|
+
expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
|
|
46
|
+
// updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
|
|
47
|
+
expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
|
|
48
|
+
for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
|
|
49
|
+
expect(by[id].completed_at_approx).toBe(1);
|
|
50
|
+
// 最后改的人不是「完成的人」:近似值不猜完成者
|
|
51
|
+
expect(by[id].completed_by_json).toBeNull();
|
|
52
|
+
// 补值不碰 updated_at
|
|
53
|
+
expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
|
|
54
|
+
}
|
|
55
|
+
for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
|
|
56
|
+
|
|
57
|
+
// 下次启动:什么都不动
|
|
58
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
59
|
+
expect(read(db)).toEqual(by);
|
|
60
|
+
|
|
61
|
+
// 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
|
|
62
|
+
db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
|
|
63
|
+
db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
|
|
64
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
|
|
65
|
+
const after = read(db);
|
|
66
|
+
expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
|
|
67
|
+
expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
|
|
68
|
+
expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
|
|
69
|
+
expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
|
|
70
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// GET /api/requirements conditional requests: ETag on every list response, 304 with an empty body
|
|
2
|
+
// when If-None-Match matches. The app polls the whole list every 15 s (production: 500 rows,
|
|
3
|
+
// 708 KB / 163 KB gzip) and it is unchanged on almost every poll. Old clients never send
|
|
4
|
+
// If-None-Match and keep getting the full 200.
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-requirements-etag-"));
|
|
11
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
12
|
+
|
|
13
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
14
|
+
let base = "";
|
|
15
|
+
let ownerToken = "", network = "";
|
|
16
|
+
|
|
17
|
+
const list = (token: string, headers: Record<string, string> = {}) =>
|
|
18
|
+
fetch(`${base}/api/requirements?network_id=${network}`, { headers: { Authorization: `Bearer ${token}`, ...headers } });
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
const { register } = await import("./auth.js");
|
|
22
|
+
const owner = register(`etag_owner_${Date.now()}`, "EtagOwnerPassw0rd!", undefined, "seed");
|
|
23
|
+
ownerToken = owner.token!; network = owner.network_id!;
|
|
24
|
+
const mod: any = await import("./server.js");
|
|
25
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
26
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
27
|
+
const created = await fetch(`${base}/api/requirements`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ name: "etag card", network_id: network }),
|
|
31
|
+
});
|
|
32
|
+
expect(created.status).toBe(201);
|
|
33
|
+
}, 30_000);
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("GET /api/requirements conditional requests", () => {
|
|
41
|
+
test("a plain GET is the same 200 JSON as before, plus ETag and private/no-cache", async () => {
|
|
42
|
+
const res = await list(ownerToken);
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
45
|
+
expect(res.headers.get("etag")).toMatch(/^W\/"[A-Za-z0-9_-]{27}"$/);
|
|
46
|
+
expect(res.headers.get("cache-control")).toBe("private, no-cache");
|
|
47
|
+
const body = await res.json() as any;
|
|
48
|
+
expect(body.ok).toBe(true);
|
|
49
|
+
expect(body.requirements.map((r: any) => r.name)).toEqual(["etag card"]);
|
|
50
|
+
expect(Array.isArray(body.capabilities)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("If-None-Match with the current ETag → 304, empty body, same ETag", async () => {
|
|
54
|
+
const etag = (await list(ownerToken)).headers.get("etag")!;
|
|
55
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
56
|
+
expect(res.status).toBe(304);
|
|
57
|
+
expect(res.headers.get("etag")).toBe(etag);
|
|
58
|
+
expect(await res.text()).toBe("");
|
|
59
|
+
// strong/weak spelling and lists are accepted
|
|
60
|
+
expect((await list(ownerToken, { "If-None-Match": etag.replace(/^W\//, "") })).status).toBe(304);
|
|
61
|
+
expect((await list(ownerToken, { "If-None-Match": `W/"stale", ${etag}` })).status).toBe(304);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("304 survives gzip negotiation (not compressed, still empty)", async () => {
|
|
65
|
+
const etag = (await list(ownerToken, { "Accept-Encoding": "gzip" })).headers.get("etag")!;
|
|
66
|
+
const res = await list(ownerToken, { "If-None-Match": etag, "Accept-Encoding": "gzip" });
|
|
67
|
+
expect(res.status).toBe(304);
|
|
68
|
+
expect(res.headers.get("content-encoding")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("any change to the list changes the ETag → full 200 again", async () => {
|
|
72
|
+
const before = await list(ownerToken);
|
|
73
|
+
const etag = before.headers.get("etag")!;
|
|
74
|
+
const id = ((await before.json()) as any).requirements[0].id;
|
|
75
|
+
const patched = await fetch(`${base}/api/requirements/${id}?network_id=${network}`, {
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ name: "etag card renamed" }),
|
|
79
|
+
});
|
|
80
|
+
expect(patched.status).toBe(200);
|
|
81
|
+
const res = await list(ownerToken, { "If-None-Match": etag });
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
expect(res.headers.get("etag")).not.toBe(etag);
|
|
84
|
+
expect(((await res.json()) as any).requirements[0].name).toBe("etag card renamed");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a stale or foreign ETag never yields 304", async () => {
|
|
88
|
+
expect((await list(ownerToken, { "If-None-Match": 'W/"not-the-current-one"' })).status).toBe(200);
|
|
89
|
+
expect((await list(ownerToken, { "If-None-Match": "" })).status).toBe(200);
|
|
90
|
+
});
|
|
91
|
+
});
|