@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.73

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.
@@ -0,0 +1,85 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // commhub-server must refuse what it does not understand instead of starting a
8
+ // Hub on the defaults (the live ~/.commhub/commhub.db). Every child gets a
9
+ // throwaway HOME and a throwaway PORT, so even a regression binds nothing real.
10
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
11
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(BIN), "..", "package.json"), "utf8")).version as string;
12
+ const home = mkdtempSync(join(tmpdir(), "anet-bin-args-"));
13
+ const PORT = String(26000 + Math.floor(Math.random() * 3000));
14
+
15
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
16
+
17
+ function run(args: string[], extraEnv: Record<string, string> = {}, timeoutMs = 15_000) {
18
+ const proc = Bun.spawnSync(["bun", BIN, ...args], {
19
+ env: { PATH: process.env.PATH ?? "", HOME: home, PORT, HOST: "127.0.0.1", ...extraEnv },
20
+ stdout: "pipe", stderr: "pipe", timeout: timeoutMs,
21
+ });
22
+ return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() };
23
+ }
24
+
25
+ async function listening(port: string): Promise<boolean> {
26
+ try { await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); return true; } catch { return false; }
27
+ }
28
+
29
+ describe("commhub-server argument handling", () => {
30
+ for (const [args, message] of [
31
+ [["bogus"], "unknown command bogus"],
32
+ [["--port=29999"], "unknown option --port=29999"],
33
+ [["--bogus-flag"], "unknown option --bogus-flag"],
34
+ [["--port"], "--port needs a value"],
35
+ [["--db", "--dev-open"], "--db needs a value"],
36
+ ] as const) {
37
+ test(`${args.join(" ")} → exit 2, no database, nothing listening`, async () => {
38
+ const r = run([...args]);
39
+ expect(r.code).toBe(2);
40
+ expect(r.stderr).toContain(message);
41
+ expect(r.stderr).toContain("commhub-server --help");
42
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
43
+ expect(await listening(PORT)).toBe(false);
44
+ });
45
+ }
46
+
47
+ test("migrate-to-pg with missing or unknown arguments exits 2 and starts nothing", async () => {
48
+ const missing = run(["migrate-to-pg", "--from", "/x.db"]);
49
+ expect(missing.code).toBe(2);
50
+ expect(missing.stdout).toContain("commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>");
51
+ const unknown = run(["migrate-to-pg", "--from", "/x.db", "--to", "postgres://u@127.0.0.1/anet_x_test", "--bogus"]);
52
+ expect(unknown.code).toBe(2);
53
+ expect(unknown.stderr).toContain("unknown argument --bogus");
54
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
55
+ expect(await listening(PORT)).toBe(false);
56
+ });
57
+
58
+ test("--help and --version exit 0 without starting", async () => {
59
+ const help = run(["--help"]);
60
+ expect(help.code).toBe(0);
61
+ expect(help.stdout).toContain("--port, -p <port>");
62
+ const version = run(["--version"]);
63
+ expect(version.code).toBe(0);
64
+ expect(version.stdout.trim()).toBe(PKG_VERSION);
65
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
66
+ });
67
+
68
+ test("the known forms still start a Hub", async () => {
69
+ const db = join(home, "known.db");
70
+ const port = String(Number(PORT) + 1);
71
+ const child = Bun.spawn(["bun", BIN, "--port", port, "--host", "127.0.0.1", "--db", db, "--dev-open"], {
72
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
73
+ });
74
+ try {
75
+ let up = false;
76
+ for (let i = 0; i < 100 && !up; i++) { up = await listening(port); if (!up) await Bun.sleep(100); }
77
+ expect(up).toBe(true);
78
+ expect(existsSync(db)).toBe(true);
79
+ expect(existsSync(join(home, ".commhub", "commhub.db"))).toBe(false);
80
+ } finally {
81
+ child.kill();
82
+ await child.exited;
83
+ }
84
+ }, 30_000);
85
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  assertSafeTestDatabaseEnv,
4
+ redactPgUrl,
4
5
  resolveDatabaseTarget,
5
6
  } from "./db-adapter";
6
7
 
@@ -83,3 +84,65 @@ describe("#435 inherited DATABASE_URL guard", () => {
83
84
  }
84
85
  });
85
86
  });
87
+
88
+ describe("RFC-039 §9 COMMHUB_TEST_PG_URL (test-only PostgreSQL)", () => {
89
+ const TEST = { ...BASE_ENV, NODE_ENV: "test" };
90
+ const ok = (url: string) => expect(resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url })).toEqual({ kind: "postgres", url });
91
+ const refused = (url: string, why: RegExp) => {
92
+ let message = "";
93
+ try { resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url }); } catch (e: any) { message = String(e?.message ?? e); }
94
+ expect(message).toMatch(/REFUSING COMMHUB_TEST_PG_URL/);
95
+ expect(message).toMatch(why);
96
+ // Credentials never reach an error message.
97
+ expect(message).not.toContain("s3cret");
98
+ expect(message).not.toContain("tester");
99
+ };
100
+
101
+ test("loopback host + anet_*_test database is accepted under NODE_ENV=test", () => {
102
+ ok("postgres://tester:s3cret@127.0.0.1:5432/anet_sched_test");
103
+ ok("postgresql://tester:s3cret@localhost/anet_side_thread_test");
104
+ ok("postgres://tester:s3cret@[::1]:6543/anet_x_test?connect_timeout=2&sslmode=disable");
105
+ });
106
+
107
+ test("ignored outside NODE_ENV=test", () => {
108
+ expect(resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "production", COMMHUB_DB: "/tmp/i.db", COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test" }))
109
+ .toEqual({ kind: "sqlite", path: "/tmp/i.db" });
110
+ });
111
+
112
+ test("inherited DATABASE_URL still refuses even when COMMHUB_TEST_PG_URL is set", () => {
113
+ expect(() => resolveDatabaseTarget({
114
+ ...TEST,
115
+ DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
116
+ COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test",
117
+ })).toThrow(/REFUSING to honor inherited DATABASE_URL/);
118
+ });
119
+
120
+ test("refuses a value that is not a URL or not postgres", () => {
121
+ refused("not a url", /not a URL/);
122
+ refused("mysql://tester:s3cret@127.0.0.1/anet_x_test", /scheme/);
123
+ });
124
+
125
+ test("refuses every host that is not literally loopback", () => {
126
+ for (const host of ["10.0.0.5", "db.internal", "localhost.evil.example", "127.0.0.2", "127.0.0.1,evil.example", "LOCALHOST", "%2Ftmp"]) {
127
+ refused(`postgres://tester:s3cret@${host}/anet_x_test`, /host must be/);
128
+ }
129
+ });
130
+
131
+ test("refuses database names outside /^anet_[a-z0-9_]*_test$/", () => {
132
+ for (const name of ["commhub", "anet_x", "anet_x_test2", "ANET_X_TEST", "anet-x_test", "anet_x_test/extra", ""]) {
133
+ refused(`postgres://tester:s3cret@127.0.0.1/${name}`, /database name/);
134
+ }
135
+ });
136
+
137
+ test("refuses connection-steering query parameters", () => {
138
+ refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?host=db.internal", /"host" is not allowed/);
139
+ refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?options=-csearch_path%3Dprod", /"options" is not allowed/);
140
+ });
141
+
142
+ test("redactPgUrl drops user and password", () => {
143
+ const out = redactPgUrl("postgres://tester:s3cret@127.0.0.1:5432/anet_x_test");
144
+ expect(out).not.toContain("tester");
145
+ expect(out).not.toContain("s3cret");
146
+ expect(out).toContain("127.0.0.1:5432/anet_x_test");
147
+ });
148
+ });
@@ -15,6 +15,12 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
15
15
  expect(out).not.toContain("TIMESTAMP");
16
16
  });
17
17
 
18
+ test("strftime with milliseconds keeps SQLite's %f shape", () => {
19
+ expect(sqliteToPostgres("VALUES (strftime('%Y-%m-%d %H:%M:%f', 'now'))")).toBe(
20
+ "VALUES (to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS'))",
21
+ );
22
+ });
23
+
18
24
  test("literal offset", () => {
19
25
  expect(sqliteToPostgres("x < datetime('now', '+14 days')")).toBe(
20
26
  "x < to_char((NOW() + INTERVAL '14 days') AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
@@ -27,6 +33,16 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
27
33
  );
28
34
  });
29
35
 
36
+ test("INTEGER → BIGINT (epoch-ms columns), identifiers containing integer untouched", () => {
37
+ expect(sqliteToPostgres("created_at INTEGER NOT NULL, integer_ref TEXT")).toBe("created_at BIGINT NOT NULL, integer_ref TEXT");
38
+ expect(sqliteToPostgres("CAST(x AS INTEGER)")).toBe("CAST(x AS BIGINT)");
39
+ });
40
+
41
+ test("REAL → DOUBLE PRECISION (SQLite REAL is 8-byte), identifiers containing real untouched", () => {
42
+ expect(sqliteToPostgres("mem_used_gb REAL, realm TEXT")).toBe("mem_used_gb DOUBLE PRECISION, realm TEXT");
43
+ expect(sqliteToPostgres("CAST(x AS REAL)")).toBe("CAST(x AS DOUBLE PRECISION)");
44
+ });
45
+
30
46
  test("BLOB → BYTEA, identifiers containing blob untouched", () => {
31
47
  expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
32
48
  });
@@ -34,7 +50,7 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
34
50
  test("placeholders and AUTOINCREMENT unchanged in behaviour", () => {
35
51
  expect(sqliteToPostgres("SELECT * FROM t WHERE a = ?1 AND b = ?2")).toBe("SELECT * FROM t WHERE a = $1 AND b = $2");
36
52
  expect(sqliteToPostgres("a = ? AND b = ?")).toBe("a = $1 AND b = $2");
37
- expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id SERIAL PRIMARY KEY");
53
+ expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id BIGSERIAL PRIMARY KEY");
38
54
  });
39
55
  });
40
56
 
package/src/db-adapter.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * - datetime('now'[, offset]) → to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')
11
11
  * (timestamps stay TEXT on both backends, RFC-039 §4.1)
12
12
  * - BLOB → BYTEA
13
- * - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
13
+ * - INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY; INTEGER → BIGINT
14
14
  * - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
15
15
  */
16
16
 
@@ -90,6 +90,17 @@ export class SQLiteAdapter implements DbAdapter {
90
90
  }
91
91
 
92
92
  close(): void {
93
+ // #2151: once more than 20 distinct SQL strings have gone through query(),
94
+ // bun:sqlite's close() can no longer really close the connection (measured:
95
+ // close(true) reports "database is locked"), so SQLite never checkpoints and
96
+ // a cleanly stopped Hub left its latest writes in -wal. Checkpoint first,
97
+ // so the .db file alone is complete after a clean stop.
98
+ try {
99
+ const r = this.rawDb.query<{ busy: number; log: number; checkpointed: number }, []>("PRAGMA wal_checkpoint(TRUNCATE)").get();
100
+ if (r && r.busy !== 0) console.warn(`[commhub] WAL checkpoint on close was busy: ${JSON.stringify(r)}`);
101
+ } catch (e) {
102
+ console.warn(`[commhub] WAL checkpoint on close failed: ${(e as Error)?.message ?? e}`);
103
+ }
93
104
  this.rawDb.close();
94
105
  }
95
106
  }
@@ -125,6 +136,9 @@ export function sqliteToPostgres(sql: string): string {
125
136
  });
126
137
  // datetime('now') → UTC text of NOW()
127
138
  s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, pgUtcText("NOW()"));
139
+ // strftime('%Y-%m-%d %H:%M:%f', 'now') → the same text with milliseconds
140
+ s = s.replace(/strftime\s*\(\s*'%Y-%m-%d %H:%M:%f'\s*,\s*'now'\s*\)/gi,
141
+ "to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS')");
128
142
  // ── Parameter placeholders ──
129
143
  // ?1, ?2 → $1, $2 (positional params)
130
144
  s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
@@ -132,8 +146,14 @@ export function sqliteToPostgres(sql: string): string {
132
146
  let idx = 0;
133
147
  s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
134
148
  // ── DDL translations ──
135
- // INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
136
- s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
149
+ // INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY
150
+ s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "BIGSERIAL PRIMARY KEY");
151
+ // INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
152
+ // milliseconds in INTEGER columns (a 32-bit PG integer overflows).
153
+ s = s.replace(/\bINTEGER\b/gi, "BIGINT");
154
+ // REAL → DOUBLE PRECISION: SQLite REAL is 8-byte; PostgreSQL REAL is 4-byte
155
+ // and would silently round telemetry and scores.
156
+ s = s.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
137
157
  // BLOB → BYTEA
138
158
  s = s.replace(/\bBLOB\b/gi, "BYTEA");
139
159
  return s;
@@ -278,13 +298,19 @@ export class PgAdapter implements DbAdapter {
278
298
  return { changes: result.count };
279
299
  }
280
300
 
301
+ /** bun:sqlite also accepts get(sql, [a, b]); a lone array argument is the parameter list. */
302
+ private static bindArgs(params: any[]): any[] | undefined {
303
+ if (params.length === 1 && Array.isArray(params[0])) return params[0];
304
+ return params.length > 0 ? params : undefined;
305
+ }
306
+
281
307
  get<T = any>(sql: string, ...params: any[]): T | null {
282
- const result = this.querySync(sql, params.length > 0 ? params : undefined);
308
+ const result = this.querySync(sql, PgAdapter.bindArgs(params));
283
309
  return (result.rows?.[0] as T) ?? null;
284
310
  }
285
311
 
286
312
  all<T = any>(sql: string, ...params: any[]): T[] {
287
- const result = this.querySync(sql, params.length > 0 ? params : undefined);
313
+ const result = this.querySync(sql, PgAdapter.bindArgs(params));
288
314
  return (result.rows as T[]) ?? [];
289
315
  }
290
316
 
@@ -373,7 +399,54 @@ export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env):
373
399
  * explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
374
400
  * one fails before mkdir/open/write instead of touching the live Hub.
375
401
  */
402
+ /**
403
+ * RFC-039 §9 — the one reviewed way a `bun test` process may use PostgreSQL.
404
+ *
405
+ * The inherited-DATABASE_URL guard stays absolute. A test that means to run
406
+ * on PostgreSQL names a *different* variable, COMMHUB_TEST_PG_URL, which is
407
+ * honoured only under NODE_ENV=test and only for a literal loopback host and
408
+ * a database called `anet_…_test`, so it cannot reach a real Hub database by
409
+ * accident. Error messages never carry credentials.
410
+ */
411
+ export const TEST_PG_URL_ENV = "COMMHUB_TEST_PG_URL";
412
+ const TEST_PG_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
413
+ const TEST_PG_DB_RE = /^anet_[a-z0-9_]*_test$/;
414
+ const TEST_PG_QUERY_KEYS = new Set(["connect_timeout", "sslmode", "application_name"]);
415
+
416
+ /** A PostgreSQL URL with user and password replaced, safe to print. */
417
+ export function redactPgUrl(raw: string): string {
418
+ try {
419
+ const u = new URL(raw);
420
+ if (u.username || u.password) { u.username = "***"; u.password = ""; }
421
+ return u.toString();
422
+ } catch {
423
+ return "<unparseable URL>";
424
+ }
425
+ }
426
+
427
+ export function validateTestPgUrl(raw: string): string {
428
+ const refuse = (why: string) => new Error(
429
+ `[commhub] REFUSING ${TEST_PG_URL_ENV}=${redactPgUrl(raw)}: ${why}.\n` +
430
+ " It is test-only: postgres(ql)://<user>:<password>@127.0.0.1|localhost|[::1][:port]/anet_<name>_test",
431
+ );
432
+ let u: URL;
433
+ try { u = new URL(raw); } catch { throw refuse("not a URL"); }
434
+ if (u.protocol !== "postgres:" && u.protocol !== "postgresql:") throw refuse("scheme must be postgres:// or postgresql://");
435
+ if (!TEST_PG_HOSTS.has(u.hostname)) throw refuse("host must be literally 127.0.0.1, localhost or ::1");
436
+ const dbName = decodeURIComponent(u.pathname.replace(/^\//, ""));
437
+ if (!TEST_PG_DB_RE.test(dbName)) throw refuse("database name must match /^anet_[a-z0-9_]*_test$/");
438
+ for (const key of u.searchParams.keys()) {
439
+ if (!TEST_PG_QUERY_KEYS.has(key)) throw refuse(`query parameter "${key}" is not allowed`);
440
+ }
441
+ return raw;
442
+ }
443
+
376
444
  function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
445
+ const testPgUrl = env[TEST_PG_URL_ENV];
446
+ if (env.NODE_ENV === "test" && testPgUrl) {
447
+ return { kind: "postgres", url: validateTestPgUrl(testPgUrl) };
448
+ }
449
+
377
450
  const dbUrl = env.DATABASE_URL;
378
451
  if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
379
452
  return { kind: "postgres", url: dbUrl };
package/src/db.ts CHANGED
@@ -2007,7 +2007,7 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
2007
2007
  `SELECT task_id, network_id, status, completed_at
2008
2008
  FROM tasks
2009
2009
  WHERE task_id = ?1
2010
- AND (?2 IS NULL OR network_id = ?2)`,
2010
+ AND (CAST(?2 AS TEXT) IS NULL OR network_id = ?2)`,
2011
2011
  taskId,
2012
2012
  expectedNetworkId ?? null,
2013
2013
  );
@@ -2073,3 +2073,9 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
2073
2073
  );
2074
2074
  } catch {}
2075
2075
  }
2076
+
2077
+ // RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
2078
+ // (insertion order). PostgreSQL has none, so give these tables a real one.
2079
+ if (db.dialect === "postgres") {
2080
+ for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2081
+ }
@@ -0,0 +1,206 @@
1
+ // 人员列表的在线状态 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
4
+ // /events/users/me);用户流上推 member_presence。两个方向都测:
5
+ // 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
6
+ // 看不到:非成员 403;节点令牌拿到的仍是原来三个身份字段;别的网络的人收不到推送;自己收不到自己的。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register } from "./auth.js";
13
+ import { db, hashToken, generateNetworkToken, generateId } from "./db.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-presence-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+
19
+ const PW = "PresenceTestPassw0rd!x";
20
+ let ownerToken = "";
21
+ let NET = "";
22
+ const users: Record<string, { token: string; userId: string }> = {};
23
+ let nodeToken = "";
24
+
25
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
26
+
27
+ async function get(token: string, path: string): Promise<{ status: number; body: any }> {
28
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
29
+ const text = await res.text();
30
+ let body: any = text;
31
+ try { body = JSON.parse(text); } catch {}
32
+ return { status: res.status, body };
33
+ }
34
+ async function post(token: string, path: string, payload: unknown): Promise<{ status: number; body: any }> {
35
+ const res = await fetch(`${BASE}${path}`, { method: "POST", headers: { ...auth(token), "Content-Type": "application/json" }, body: JSON.stringify(payload) });
36
+ const text = await res.text();
37
+ let body: any = text;
38
+ try { body = JSON.parse(text); } catch {}
39
+ return { status: res.status, body };
40
+ }
41
+ async function login(username: string): Promise<{ token: string; userId: string }> {
42
+ const r = await post("", "/api/auth/login", { username, password: PW });
43
+ expect(r.status).toBe(200);
44
+ return { token: r.body.token, userId: r.body.user.user_id };
45
+ }
46
+
47
+ /** 一条用户流:收集 data: 帧,close() 模拟 app 退出。 */
48
+ async function openUserStream(token: string, networkId: string) {
49
+ const ctrl = new AbortController();
50
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(networkId)}`, { headers: auth(token), signal: ctrl.signal });
51
+ expect(res.status).toBe(200);
52
+ const events: any[] = [];
53
+ const reader = res.body!.getReader();
54
+ const decoder = new TextDecoder();
55
+ let buf = "";
56
+ void (async () => {
57
+ try {
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ buf += decoder.decode(value);
62
+ let i: number;
63
+ while ((i = buf.indexOf("\n\n")) >= 0) {
64
+ const frame = buf.slice(0, i);
65
+ buf = buf.slice(i + 2);
66
+ for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6)));
67
+ }
68
+ }
69
+ } catch { /* aborted */ }
70
+ })();
71
+ await waitFor(() => events.some((e) => e.type === "connected"));
72
+ return { events, close: () => ctrl.abort(), presence: () => events.filter((e) => e.type === "member_presence") };
73
+ }
74
+
75
+ async function waitFor(cond: () => boolean | Promise<boolean>, ms = 3000): Promise<void> {
76
+ const until = Date.now() + ms;
77
+ while (Date.now() < until) {
78
+ if (await cond()) return;
79
+ await new Promise((r) => setTimeout(r, 20));
80
+ }
81
+ throw new Error("waitFor timed out");
82
+ }
83
+
84
+ const humanOf = async (token: string, userId: string) =>
85
+ (await get(token, `/api/networks/${NET}/humans`)).body.humans.find((h: any) => h.user_id === userId);
86
+
87
+ beforeAll(async () => {
88
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
89
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
90
+ process.env.HOST = "127.0.0.1";
91
+
92
+ const owner = register(`pres_owner_${Date.now()}`, PW, undefined, "Owner");
93
+ expect(owner.ok).toBe(true);
94
+ ownerToken = owner.token!;
95
+ NET = owner.network_id!;
96
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [owner.user!.user_id]);
97
+ users.owner = { token: ownerToken, userId: owner.user!.user_id };
98
+
99
+ const mod: any = await import("./server.js");
100
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
101
+ BASE = `http://127.0.0.1:${hub.port}`;
102
+
103
+ // 受限成员(默认 agent_access=granted)、viewer、普通成员;外人自己一个网络。
104
+ for (const [name, role] of [["pres_alpha", "member"], ["pres_beta", "member"], ["pres_gamma", "viewer"]] as const) {
105
+ const r = await post(ownerToken, "/api/admin/users", { username: name, password: PW, network_id: NET, role });
106
+ expect(r.status).toBe(200);
107
+ users[name] = await login(name);
108
+ }
109
+ const outsider = register(`pres_out_${Date.now()}`, PW);
110
+ expect(outsider.ok).toBe(true);
111
+ users.outsider = { token: outsider.token!, userId: outsider.user!.user_id };
112
+ users.outsider_net = { token: outsider.token!, userId: outsider.network_id! };
113
+
114
+ nodeToken = generateNetworkToken();
115
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, 'pres-node', 'network')",
116
+ [generateId("tok"), hashToken(nodeToken), users.owner.userId, NET]);
117
+ }, 30_000);
118
+
119
+ afterAll(() => {
120
+ try { hub?.stop?.(true); } catch {}
121
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
122
+ });
123
+
124
+ describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
125
+ test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
126
+ const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
127
+ expect(r.status).toBe(200);
128
+ expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
129
+ const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
130
+ expect(beta.online).toBe(false);
131
+ expect(beta.last_seen_at).toBeNull();
132
+ });
133
+
134
+ test("user stream open → online for member, restricted member and viewer; closed → offline with last_seen_at", async () => {
135
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
136
+ const before = Date.now();
137
+ const beta = await openUserStream(users.pres_beta.token, NET);
138
+
139
+ for (const viewer of ["pres_alpha", "pres_gamma", "owner"]) {
140
+ const h = await humanOf(users[viewer].token, users.pres_beta.userId);
141
+ expect(h.online).toBe(true);
142
+ expect(Date.parse(h.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
143
+ }
144
+ // 推送:alpha 收到 beta 上线;beta 收不到自己的。
145
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === true));
146
+ const up = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId)!;
147
+ expect(up.network_id).toBe(NET);
148
+ expect(up.user_id).toBe(users.pres_alpha.userId); // 收件人
149
+ expect(beta.presence().filter((e) => e.member_user_id === users.pres_beta.userId)).toEqual([]);
150
+
151
+ beta.close();
152
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
153
+ const off = await humanOf(users.pres_gamma.token, users.pres_beta.userId);
154
+ expect(off.online).toBe(false);
155
+ expect(Date.parse(off.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
156
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === false));
157
+ const down = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId && e.online === false)!;
158
+ expect(typeof down.last_seen_at).toBe("string");
159
+ watcher.close();
160
+ });
161
+
162
+ test("two streams for one user: closing one keeps them online, no offline event", async () => {
163
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
164
+ const a = await openUserStream(users.pres_beta.token, NET);
165
+ const b = await openUserStream(users.pres_beta.token, NET);
166
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
167
+ a.close();
168
+ await new Promise((r) => setTimeout(r, 200));
169
+ expect((await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online).toBe(true);
170
+ expect(watcher.presence().filter((e) => e.member_user_id === users.pres_beta.userId && !e.online)).toEqual([]);
171
+ b.close();
172
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
173
+ watcher.close();
174
+ });
175
+ });
176
+
177
+ describe("who must NOT see presence", () => {
178
+ test("non-member → 403, no humans at all", async () => {
179
+ const r = await get(users.outsider.token, `/api/networks/${NET}/humans`);
180
+ expect(r.status).toBe(403);
181
+ expect(r.body.humans).toBeUndefined();
182
+ });
183
+
184
+ test("node token → same three identity fields as before, no online / last_seen_at", async () => {
185
+ const beta = await openUserStream(users.pres_beta.token, NET);
186
+ const r = await get(nodeToken, `/api/networks/${NET}/humans`);
187
+ expect(r.status).toBe(200);
188
+ for (const h of r.body.humans) expect(Object.keys(h).sort()).toEqual(["display_name", "user_id", "username"]);
189
+ // 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
190
+ expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
191
+ beta.close();
192
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
193
+ });
194
+
195
+ test("a user connected in another network gets no member_presence about this network's people", async () => {
196
+ const outsiderStream = await openUserStream(users.outsider.token, users.outsider_net.userId);
197
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
198
+ const beta = await openUserStream(users.pres_beta.token, NET);
199
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
200
+ beta.close();
201
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && !e.online));
202
+ expect(outsiderStream.presence()).toEqual([]);
203
+ watcher.close();
204
+ outsiderStream.close();
205
+ });
206
+ });
@@ -0,0 +1,47 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MigrateRefused, migrateSqliteToPg } from "./migrate-sqlite-to-pg";
6
+
7
+ // Only the refusals that happen before any connection is attempted; the full
8
+ // copy runs against a real PostgreSQL in test2123 (migrate-stage.sh).
9
+ const home = mkdtempSync(join(tmpdir(), "anet-migrate-home-"));
10
+ const commhub = join(home, ".commhub");
11
+ mkdirSync(commhub);
12
+ writeFileSync(join(commhub, "commhub.db"), "");
13
+ const elsewhere = join(home, "copies");
14
+ mkdirSync(elsewhere);
15
+ symlinkSync(join(commhub, "commhub.db"), join(elsewhere, "link.db"));
16
+ const PG = "postgres://user:s3cret@127.0.0.1:1/anet_never_test";
17
+
18
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
19
+
20
+ async function refusal(opts: Parameters<typeof migrateSqliteToPg>[0]): Promise<string> {
21
+ try {
22
+ await migrateSqliteToPg({ ...opts, home, log: () => {} });
23
+ } catch (e) {
24
+ expect(e).toBeInstanceOf(MigrateRefused);
25
+ return String((e as Error).message);
26
+ }
27
+ throw new Error("expected a refusal");
28
+ }
29
+
30
+ describe("RFC-039 S5 migrate-to-pg refusals", () => {
31
+ test("target must be a postgres URL", async () => {
32
+ expect(await refusal({ from: join(elsewhere, "x.db"), to: "mysql://h/db" })).toContain("--to must be a postgres");
33
+ });
34
+
35
+ test("missing source", async () => {
36
+ expect(await refusal({ from: join(elsewhere, "missing.db"), to: PG })).toContain("does not exist");
37
+ });
38
+
39
+ test("the live Hub directory ~/.commhub is refused, also through a symlink", async () => {
40
+ for (const from of [join(commhub, "commhub.db"), join(elsewhere, "link.db")]) {
41
+ const message = await refusal({ from, to: PG });
42
+ expect(message).toContain("live Hub's directory");
43
+ expect(message).toContain("--i-know-this-is-a-copy");
44
+ expect(message).not.toContain("s3cret");
45
+ }
46
+ });
47
+ });