dsh-lan-peers 0.7.2 → 0.7.4

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.
@@ -89,6 +89,12 @@ export async function startCluster(t, options = {}) {
89
89
  commitTimeoutMs: commitOverride = commitTimeoutMs,
90
90
  tickMs: tickOverride = tickMs,
91
91
  jitterMs = 0,
92
+ // S3 补充:哪些下标是「hub 角色」(配置上没有 group.hub,可参与启动接管)。默认只有 0。
93
+ hubRoles = [0],
94
+ // 预置角色文件:{ 下标: leader 下标 } → 该节点以 follower 起步(leader 指向另一台)
95
+ followerOf = {},
96
+ // 假时钟(「推进 30s 验证不再重试」这类断言用)
97
+ now: nowOverride = null,
92
98
  } = options;
93
99
 
94
100
  const ids = customIds ?? Array.from({ length: count }, (_, index) => `peer-s3-${String.fromCharCode(97 + index)}`);
@@ -106,9 +112,46 @@ export async function startCluster(t, options = {}) {
106
112
 
107
113
  const nodes = [];
108
114
  const urls = [];
115
+ const hubRoleIndexes = new Set(hubRoles);
116
+ hubRoleIndexes.add(0);
117
+ /** node0 的群快照:后面的 hub 角色节点要复用同一 groupId/groupKey */
118
+ let hubSnapshot = null;
109
119
  for (let index = 0; index < count; index += 1) {
110
120
  // 先写 peers.json 再 listen:成员启动那一轮就能解析到 hub
111
121
  writePeersJson(dirs[index], ids, names, urls, index);
122
+ const isHubRole = hubRoleIndexes.has(index);
123
+ if (index > 0 && isHubRole && hubSnapshot) {
124
+ // 第二个 hub 角色节点:先把 group.json 写成与 node0 同一把群密钥/groupId,
125
+ // 否则 GroupStore 首次 load 会自己生成一份新的(那就不是一个群了)
126
+ const members = {};
127
+ for (let other = 0; other < index; other += 1) {
128
+ members[ids[other]] = { name: names[other], baseUrl: urls[other], addedAt: new Date().toISOString() };
129
+ }
130
+ members[ids[index]] = { name: names[index], addedAt: new Date().toISOString() };
131
+ writeJson0600(path.join(dirs[index], 'group.json'), {
132
+ groupId: hubSnapshot.groupId,
133
+ key: hubSnapshot.groupKey,
134
+ keyVersion: hubSnapshot.epoch,
135
+ name: hubSnapshot.name,
136
+ createdAt: new Date().toISOString(),
137
+ members,
138
+ messages: [],
139
+ pending: [],
140
+ lastSeq: 0,
141
+ });
142
+ }
143
+ if (index in followerOf) {
144
+ // 预置角色文件:以 follower 起步(leader 指向 followerOf[index] 那台)
145
+ writeJson0600(path.join(dirs[index], 'group-role.json'), {
146
+ role: 'follower',
147
+ epoch: hubSnapshot ? hubSnapshot.epoch : 1,
148
+ leaderPeerId: ids[followerOf[index]],
149
+ leaseUntil: Date.now() + 60_000,
150
+ commitSeq: 0,
151
+ votedFor: null,
152
+ groupId: hubSnapshot ? hubSnapshot.groupId : '',
153
+ });
154
+ }
112
155
  const group = {
113
156
  enabled: true,
114
157
  name: 's3',
@@ -119,11 +162,12 @@ export async function startCluster(t, options = {}) {
119
162
  commitTimeoutMs: commitOverride,
120
163
  hubPriority: priorities[index] ?? 0,
121
164
  };
122
- if (index > 0) group.hub = names[0];
165
+ if (!isHubRole) group.hub = names[0];
123
166
  const server = createPeerServer({
124
167
  listenHost: '127.0.0.1',
125
168
  stateDir: dirs[index],
126
169
  requireEncryption,
170
+ ...(nowOverride ? { now: nowOverride } : {}),
127
171
  allowRemotePrompts: true,
128
172
  logger,
129
173
  group,
@@ -140,6 +184,11 @@ export async function startCluster(t, options = {}) {
140
184
  const info = await server.listen(0, '127.0.0.1');
141
185
  server.config.group.advertiseBaseUrl = info.url;
142
186
  urls[index] = info.url;
187
+ if (isHubRole) {
188
+ const self = server.group.state().members[ids[index]];
189
+ if (self) self.baseUrl = info.url;
190
+ if (!hubSnapshot) hubSnapshot = server.group.state({ includeKey: true });
191
+ }
143
192
  nodes.push({
144
193
  index,
145
194
  peerId: ids[index],
@@ -160,6 +209,16 @@ export async function startCluster(t, options = {}) {
160
209
  hub.server.group.state().members[ids[index]].baseUrl = urls[index];
161
210
  }
162
211
 
212
+ // 第二个及以后的 hub 角色节点:成员表补齐(自己那条地址在 listen 后已写)
213
+ for (const node of nodes.slice(1)) {
214
+ if (!node.server.group.isHub) continue;
215
+ for (let index = 0; index < count; index += 1) {
216
+ if (index === node.index) continue;
217
+ node.server.group.addMember(ids[index], names[index]);
218
+ node.server.group.state().members[ids[index]].baseUrl = urls[index];
219
+ }
220
+ }
221
+
163
222
  const snapshot = hub.server.group.state({ includeKey: true });
164
223
  for (let index = 1; index < count; index += 1) {
165
224
  const members = {};
@@ -49,6 +49,8 @@ test('S3 majority:propose→commit 全链路,三台都可见且 commitSeq
49
49
  assert.equal(a.server.group.state().pendingCount, 0, '提交后 pending 必须清空');
50
50
 
51
51
  // 日志:一行 post、一行 commit、至少一次心跳;不含正文/密钥
52
+ // 心跳按 heartbeatMs(默认 120ms)广播,用例本身只要 ~200ms —— 必须**等事件**,不能靠时序。
53
+ await waitFor(() => lines.some((line) => line.startsWith('[group] heartbeat ')), { timeoutMs: 3_000 });
52
54
  const logText = lines.join('\n');
53
55
  assert.match(logText, /\[group\] post kind=chat seq=1 /);
54
56
  assert.match(logText, /\[group\] commit seq=1 /);
@@ -203,7 +205,8 @@ test('S3 seq 连续无重复:失败的提案不消耗号,恢复后重试拿
203
205
  assert.equal(first.status, 201);
204
206
  assert.equal(first.json.seq, 1);
205
207
 
206
- // 把两台成员的地址改成打不通:只拿得到自己一票 → 503
208
+ // 把两台成员的地址改成打不通:只拿得到自己一票 → 503。
209
+ // 群成员表里的 baseUrl 是权威寻址(baseUrlOf 优先读它),所以这里改完立刻生效。
207
210
  const members = a.server.group.state().members;
208
211
  members[b.peerId].baseUrl = 'http://127.0.0.1:1';
209
212
  members[c.peerId].baseUrl = 'http://127.0.0.1:1';
@@ -264,6 +267,50 @@ test('S3 成员当 leader 且要求加密时:post 走 X-Peer-Enc 也能两阶
264
267
  // hub 侧靠 commit 转正(加密通道同理)
265
268
  await waitFor(() => a.server.group.history({ sinceSeq: 0 }).length === 1);
266
269
  });
270
+ test('S3 取号闸门:pending 占着的号不会被单阶段写(面板/管理接口)复用', async (t) => {
271
+ const cluster = await startCluster(t, { count: 3, commitTimeoutMs: 2_000 });
272
+ const [a, b, c] = cluster.nodes;
273
+ // 黑洞:提案会一直挂在 pending 里,直到 commitTimeoutMs 才丢弃
274
+ const sockets = new Set();
275
+ const blackhole = net.createServer((socket) => {
276
+ sockets.add(socket);
277
+ socket.on('close', () => sockets.delete(socket));
278
+ socket.on('error', () => sockets.delete(socket));
279
+ });
280
+ await new Promise((resolve) => blackhole.listen(0, '127.0.0.1', resolve));
281
+ t.after(() => {
282
+ for (const socket of sockets) socket.destroy();
283
+ blackhole.close();
284
+ });
285
+ blackhole.unref();
286
+ const holeUrl = `http://127.0.0.1:${blackhole.address().port}`;
287
+ const members = a.server.group.state().members;
288
+ members[b.peerId].baseUrl = holeUrl;
289
+ members[c.peerId].baseUrl = holeUrl;
290
+
291
+ const inflight = postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'slow-proposal' });
292
+ await waitFor(() => a.server.group.state().pendingCount === 1, { timeoutMs: 1_500 });
293
+ const pendingSeq = a.server.group.state().lastSeq + 1;
294
+ assert.equal(a.server.group.state().lastSeq, 0, '提案未提交,lastSeq 不推进');
295
+
296
+ // 面板/管理接口走单阶段写(server.group.post → GroupStore.append):必须避开 pending 占的号
297
+ const panel = a.server.group.post({ peerId: 'peer-panel', name: 'panel', kind: 'chat', text: 'panel-msg' });
298
+ assert.equal(panel.seq, pendingSeq + 1, '单阶段写不得复用 pending 占着的 seq');
299
+
300
+ const failed = await inflight;
301
+ assert.equal(failed.status, 503, failed.text);
302
+ assert.equal(a.server.group.state().pendingCount, 0, '超时后 pending 必须清空');
303
+
304
+ // 恢复可达后继续写:号必须继续往前走,且任何两条消息不共用 seq
305
+ members[b.peerId].baseUrl = b.baseUrl;
306
+ members[c.peerId].baseUrl = c.baseUrl;
307
+ const next = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'after-drop' });
308
+ assert.equal(next.status, 201, next.text);
309
+ assert.equal(next.json.seq, panel.seq + 1);
310
+ const seqs = a.server.group.history({ sinceSeq: 0 }).map((m) => m.seq);
311
+ assert.deepEqual(seqs, [panel.seq, panel.seq + 1]);
312
+ assert.equal(new Set(seqs).size, seqs.length, 'seq 不得重复(客户端按 seq 去重)');
313
+ });
267
314
  test('S3 提交串行化:连续多条 post 的 seq 严格连续(并发下也不撞号)', async (t) => {
268
315
  const cluster = await startCluster(t, { count: 3 });
269
316
  const [a, b] = cluster.nodes;
@@ -11,8 +11,11 @@ import fs from 'node:fs';
11
11
  import { setTimeout as sleep } from 'node:timers/promises';
12
12
  import {
13
13
  fetchGroupLease,
14
+ pullGroupMessages,
15
+ readGroupKeyFile,
14
16
  requestGroupVote,
15
17
  sendGroupHeartbeat,
18
+ writeGroupKeyFile,
16
19
  } from '../../lib/group-client.mjs';
17
20
  import { fileMode, makeTempStateDir } from './helpers.mjs';
18
21
  import { postAs, readRoleFile, roleOf, startCluster, waitFor } from './group-cluster.mjs';
@@ -362,7 +365,26 @@ test('S3 quorum 分母 = 成员表全集:没有任何地址的成员也计入
362
365
  for (const id of ['peer-ghost-1', 'peer-ghost-2', 'peer-ghost-3']) a.server.group.removeMember(id);
363
366
  const ok = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'ghost-gone' });
364
367
  assert.equal(ok.status, 201, ok.text);
365
- assert.equal((await lease(a)).quorumNeeded, 2);
368
+ // 移除成员会轮换群密钥(keyVersion+1):旧钥必须被拒,新钥照常
369
+ const rotated = a.server.group.state({ includeKey: true });
370
+ assert.ok(rotated.keyVersion > 1, 'removeMember 必须轮换 keyVersion');
371
+ assert.notEqual(rotated.groupKey, cluster.groupKey, '轮换后 groupKey 必须变化');
372
+ await assert.rejects(
373
+ () => fetchGroupLease({ baseUrl: a.baseUrl, groupKey: cluster.groupKey, groupId: cluster.groupId, senderId: b.peerId }),
374
+ (error) => {
375
+ assert.equal(error.status, 401);
376
+ assert.equal(error.code, 'bad-signature');
377
+ return true;
378
+ },
379
+ );
380
+ const after = await fetchGroupLease({
381
+ baseUrl: a.baseUrl,
382
+ groupKey: rotated.groupKey,
383
+ groupId: cluster.groupId,
384
+ senderId: b.peerId,
385
+ });
386
+ assert.equal(after.quorumNeeded, 2);
387
+ assert.equal(after.keyVersion, rotated.keyVersion, 'lease 必须暴露 keyVersion');
366
388
  });
367
389
 
368
390
  test('S3 单节点自立为 leader 必败:N=2 且另一台不可达 → quorum-unavailable', async (t) => {
@@ -399,6 +421,47 @@ test('S3 N=3:两台可达能当选(2 ≥ 2),只剩一台不能(1 < 2
399
421
  assert.equal(lonely.needed, 2);
400
422
  assert.notEqual(roleOf(c).role, 'leader');
401
423
  });
424
+ test('S3 成员表未同步时:选举/心跳自愈入表(真机 not-a-member 的回归)', async (t) => {
425
+ const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
426
+ const [a, b, c] = cluster.nodes;
427
+ // 造「c 的成员表来自一份不完整的快照」:把 b 从 c 的 group-key.json 里删掉
428
+ const keyFile = readGroupKeyFile(c.dir);
429
+ delete keyFile.members[b.peerId];
430
+ writeGroupKeyFile(c.dir, keyFile);
431
+ const stale = readGroupKeyFile(c.dir);
432
+ assert.equal(Boolean(stale.members[b.peerId]), false, '前提:c 不认识 b');
433
+
434
+ // b 拉票:原来会 401 not-a-member(对端永远投不进来 → 群选不出 leader)
435
+ const vote = await requestGroupVote({
436
+ baseUrl: c.baseUrl,
437
+ ...groupArgs(cluster, b.peerId),
438
+ epoch: 3,
439
+ candidate: b.peerId,
440
+ });
441
+ assert.equal(vote.vote, true, JSON.stringify(vote));
442
+ assert.equal(vote.epoch, 3);
443
+ // 自愈:c 把 b 补回成员表(并落盘)
444
+ const healed = readGroupKeyFile(c.dir);
445
+ assert.ok(healed.members[b.peerId], 'c 必须把 b 补回成员表并落盘');
446
+ // 第二次直接投,不再走自愈分支
447
+ const again = await requestGroupVote({
448
+ baseUrl: c.baseUrl,
449
+ ...groupArgs(cluster, b.peerId),
450
+ epoch: 4,
451
+ candidate: b.peerId,
452
+ });
453
+ assert.equal(again.vote, true);
454
+
455
+ // 非领导权路径仍然严格:成员表没有的发送方打 pull 仍 401 not-a-member(不自愈)
456
+ await assert.rejects(
457
+ () => pullGroupMessages({ baseUrl: c.baseUrl, ...groupArgs(cluster, 'peer-not-in-group') }),
458
+ (error) => {
459
+ assert.equal(error.status, 401);
460
+ assert.equal(error.code, 'not-a-member');
461
+ return true;
462
+ },
463
+ );
464
+ });
402
465
  // 说明:post 走 per-peer 签名,这里顺带确认 leader 真的能收(详情见 group-commit.test.mjs)
403
466
  test('S3 leader 就绪:hub leader 的 post 通道可用(202/201 由 commit 测试覆盖)', async (t) => {
404
467
  const cluster = await startCluster(t, { count: 2, quorum: 'none' });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * v0.7.3 安全硬化 · 移除成员即轮换群密钥 + 成员侧换钥自愈(契约补充)。
3
+ *
4
+ * 两个洞的回归:
5
+ * ① hub 收到「不在成员表但握着有效 groupKey」的心跳时必须拒绝,且**不得**把它写回成员表
6
+ * ② removeMember 必须轮换 keyVersion;被移除者的旧钥一律 401 bad-signature
7
+ * 另外两条保护:③成员侧换钥后自动重试一次 ④成员侧(副本)自愈仍然有效
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { setTimeout as sleep } from 'node:timers/promises';
12
+ import {
13
+ fetchGroupLease,
14
+ readGroupKeyFile,
15
+ requestGroupVote,
16
+ sendGroupHeartbeat,
17
+ writeGroupKeyFile,
18
+ } from '../../lib/group-client.mjs';
19
+ import { postAs, roleOf, startCluster, waitFor } from './group-cluster.mjs';
20
+
21
+ const groupArgs = (cluster, senderId) => ({
22
+ groupKey: cluster.groupKey,
23
+ groupId: cluster.groupId,
24
+ senderId,
25
+ });
26
+
27
+ test('① hub 永不自愈:持有有效 groupKey 但不在成员表 → 401 not-a-member,且不写回成员表', async (t) => {
28
+ const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
29
+ const [hub] = cluster.nodes;
30
+ const before = Object.keys(hub.server.group.state().members).sort();
31
+ const outsider = 'peer-not-invited-9f3a';
32
+
33
+ const heartbeat = await assert.rejects(
34
+ () => sendGroupHeartbeat({
35
+ baseUrl: hub.baseUrl,
36
+ ...groupArgs(cluster, outsider),
37
+ epoch: 1,
38
+ leaderPeerId: outsider,
39
+ }),
40
+ (error) => {
41
+ assert.equal(error.status, 401);
42
+ assert.equal(error.code, 'not-a-member');
43
+ return true;
44
+ },
45
+ );
46
+ assert.equal(heartbeat, undefined);
47
+ // 投票端点同样是领导权协议,但 hub 侧也不得自愈
48
+ await assert.rejects(
49
+ () => requestGroupVote({
50
+ baseUrl: hub.baseUrl,
51
+ ...groupArgs(cluster, outsider),
52
+ epoch: 2,
53
+ candidate: outsider,
54
+ }),
55
+ (error) => {
56
+ assert.equal(error.status, 401);
57
+ assert.equal(error.code, 'not-a-member');
58
+ return true;
59
+ },
60
+ );
61
+ assert.deepEqual(Object.keys(hub.server.group.state().members).sort(), before, 'group.json 的成员表不得被自愈写入');
62
+ assert.equal(readGroupKeyFile(hub.dir), null, 'hub 不写 group-key.json');
63
+ });
64
+
65
+ test('② 移除成员即轮换密钥:keyVersion+1,被移除者用旧钥打 election/leader → 401 bad-signature', async (t) => {
66
+ const lines = [];
67
+ const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000, logger: (line) => lines.push(String(line)) });
68
+ const [hub, , removed] = cluster.nodes;
69
+ const before = hub.server.group.state({ includeKey: true });
70
+ assert.equal(before.keyVersion, 1);
71
+
72
+ assert.equal(hub.server.group.removeMember(removed.peerId), true);
73
+ const after = hub.server.group.state({ includeKey: true });
74
+ assert.equal(after.keyVersion, before.keyVersion + 1, '移除成员必须抬 keyVersion');
75
+ assert.notEqual(after.groupKey, before.groupKey, '必须换一把新 groupKey');
76
+ assert.equal(after.members[removed.peerId], undefined, '成员表里不能再有它');
77
+ // 日志:一行摘要,且绝不打印密钥本身
78
+ const rotatedLine = lines.find((line) => line.startsWith('[group] key rotated '));
79
+ assert.ok(rotatedLine, '必须有 [group] key rotated 日志');
80
+ assert.match(rotatedLine, new RegExp(`keyVersion=${after.keyVersion} reason=member-removed`));
81
+ assert.equal(rotatedLine.includes(after.groupKey), false, '日志不得打印密钥');
82
+ assert.equal(rotatedLine.includes(before.groupKey), false, '日志不得打印旧密钥');
83
+ if (process.platform !== 'win32') {
84
+ const mode = (await import('node:fs')).statSync(hub.server.group.state ? hub.dir + '/group.json' : '').mode & 0o777;
85
+ assert.equal(mode, 0o600, 'group.json 仍必须 0600');
86
+ }
87
+
88
+ // 被移除者仍握着旧钥(它的 group-key.json 没变)→ 一律拒绝,且**不是** not-a-member。
89
+ // 有 body 的请求(心跳/投票)在信封层就先炸:400 bad-encryption;
90
+ // 无 body 的请求(GET /v1/group/lease)能走到验签:401 bad-signature。两者都证明旧钥已失效。
91
+ const rejectedWithOldKey = (error) => {
92
+ assert.equal(error.status === 401 || error.status === 400, true, '状态必须是 401/400,实际 ' + error.status);
93
+ assert.equal(['bad-signature', 'bad-encryption'].includes(String(error.code)), true, '错误码 ' + error.code);
94
+ assert.notEqual(String(error.code), 'not-a-member', '不能只是 not-a-member —— 说明密钥没轮换');
95
+ return true;
96
+ };
97
+ await assert.rejects(
98
+ () => sendGroupHeartbeat({
99
+ baseUrl: hub.baseUrl,
100
+ ...groupArgs(cluster, removed.peerId),
101
+ epoch: 2,
102
+ leaderPeerId: removed.peerId,
103
+ }),
104
+ rejectedWithOldKey,
105
+ );
106
+ await assert.rejects(
107
+ () => requestGroupVote({
108
+ baseUrl: hub.baseUrl,
109
+ ...groupArgs(cluster, removed.peerId),
110
+ epoch: 2,
111
+ candidate: removed.peerId,
112
+ }),
113
+ rejectedWithOldKey,
114
+ );
115
+ // 无 body 的那条给出精确的 401 bad-signature(验签先于成员表检查)
116
+ await assert.rejects(
117
+ () => fetchGroupLease({
118
+ baseUrl: hub.baseUrl,
119
+ ...groupArgs(cluster, removed.peerId),
120
+ }),
121
+ (error) => {
122
+ assert.equal(error.status, 401);
123
+ assert.equal(error.code, 'bad-signature');
124
+ return true;
125
+ },
126
+ );
127
+ // hub 侧不再把它算进多数派分母
128
+ const lease = await fetchGroupLease({
129
+ baseUrl: hub.baseUrl,
130
+ groupKey: after.groupKey,
131
+ groupId: cluster.groupId,
132
+ senderId: cluster.nodes[1].peerId,
133
+ });
134
+ assert.equal(lease.memberCount, 2);
135
+ assert.equal(lease.keyVersion, after.keyVersion);
136
+ });
137
+
138
+ test('②b 被移除的成员不能靠自助 join 回来(同一个洞的另一扇门)', async (t) => {
139
+ const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
140
+ const [hub, member] = cluster.nodes;
141
+ assert.equal(hub.server.group.state().members[member.peerId] !== undefined, true);
142
+ hub.server.group.removeMember(member.peerId);
143
+ // 成员用 per-peer 签名打 /v1/group/join(autoJoin 是开的)——原来会被加回成员表
144
+ const res = await postAs(hub.baseUrl, member.peerId, { name: 'sneak-back' }, { path: '/v1/group/join' });
145
+ assert.equal(res.status, 403, res.text);
146
+ assert.equal(res.json.error, 'not-a-member');
147
+ assert.equal(hub.server.group.state().members[member.peerId], undefined, '成员表不得被 join 写回');
148
+ // 运维显式重新邀请(addMember)会解除 blocked,之后 join 也恢复正常语义
149
+ hub.server.group.addMember(member.peerId, 'node-b');
150
+ assert.equal(hub.server.group.state().members[member.peerId] !== undefined, true);
151
+ });
152
+ test('③ 成员换钥:hub 轮换后首次请求被拒 → 自动 refetch state → 重试一次成功', async (t) => {
153
+ const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
154
+ const [hub, member] = cluster.nodes;
155
+ // 让 hub 轮换密钥,但 member 仍在成员表里(模拟「踢掉第三台」后的换钥)
156
+ hub.server.group.addMember('peer-ghost-1', 'ghost');
157
+ hub.server.group.removeMember('peer-ghost-1');
158
+ const rotated = hub.server.group.state({ includeKey: true });
159
+ assert.ok(rotated.keyVersion > 1);
160
+ assert.notEqual(readGroupKeyFile(member.dir)?.key, rotated.groupKey, '前提:成员手里还是旧钥');
161
+ assert.equal(hub.server.group.members().some((entry) => entry.peerId === member.peerId), true);
162
+
163
+ // 成员的下一次出站群请求(拉补差)会先撞 401 bad-signature → 换钥 → 重试一次
164
+ let out = await member.server.group.pull();
165
+ for (let attempt = 0; attempt < 40 && out?.reason === 'skipped'; attempt += 1) {
166
+ await sleep(25);
167
+ out = await member.server.group.pull();
168
+ }
169
+ assert.equal(out.ok, true, JSON.stringify(out));
170
+ const refreshed = readGroupKeyFile(member.dir);
171
+ assert.equal(refreshed.key, rotated.groupKey, '成员必须把新钥落进 group-key.json');
172
+ assert.equal(roleOf(member).keyVersion, rotated.keyVersion, 'role() 必须暴露新 keyVersion');
173
+ // 只重试一次:再拉一次应当直接成功
174
+ const second = await member.server.group.pull();
175
+ assert.equal(second.ok, true);
176
+ });
177
+
178
+ test('④ 成员侧(副本)自愈仍然有效:表未同步但未移除的成员,election 仍被接受并补表', async (t) => {
179
+ const cluster = await startCluster(t, { count: 3, heartbeatMs: 5_000, tickMs: 60_000 });
180
+ const [, b, c] = cluster.nodes;
181
+ // 造「c 的成员表来自不完整快照」:把 b 从 c 的 group-key.json 里删掉
182
+ const keyFile = readGroupKeyFile(c.dir);
183
+ delete keyFile.members[b.peerId];
184
+ writeGroupKeyFile(c.dir, keyFile);
185
+ assert.equal(Boolean(readGroupKeyFile(c.dir).members[b.peerId]), false);
186
+
187
+ const vote = await requestGroupVote({
188
+ baseUrl: c.baseUrl,
189
+ ...groupArgs(cluster, b.peerId),
190
+ epoch: 3,
191
+ candidate: b.peerId,
192
+ });
193
+ assert.equal(vote.vote, true, JSON.stringify(vote));
194
+ assert.ok(readGroupKeyFile(c.dir).members[b.peerId], '成员侧必须把对端补回成员表');
195
+ });
196
+
197
+ test('⑥ 轮换后 epoch 不回退:重启(新的 server 实例读同一份状态)时角色 epoch 不小于 keyVersion', async (t) => {
198
+ const cluster = await startCluster(t, { count: 2, heartbeatMs: 5_000, tickMs: 60_000 });
199
+ const [hub] = cluster.nodes;
200
+ hub.server.group.addMember('peer-ghost-2', 'ghost');
201
+ hub.server.group.removeMember('peer-ghost-2');
202
+ const keyVersion = hub.server.group.state().keyVersion;
203
+ assert.ok(keyVersion >= 2);
204
+ // 同一份 stateDir 再起一个实例(等价于重启):角色 epoch 必须 ≥ keyVersion
205
+ const { createPeerServer } = await import('../server.mjs');
206
+ const restarted = createPeerServer({
207
+ listenHost: '127.0.0.1',
208
+ stateDir: hub.dir,
209
+ requireEncryption: false,
210
+ group: { enabled: true, name: 's3', quorum: 'none', heartbeatMs: 5_000 },
211
+ groupElection: { heartbeatMs: 5_000, tickMs: 60_000, peerTimeoutMs: 200 },
212
+ });
213
+ t.after(async () => { await restarted.close(); });
214
+ const info = await restarted.listen(0, '127.0.0.1');
215
+ assert.equal(info.port > 0, true);
216
+ const role = restarted.group.role();
217
+ assert.ok(role.epoch >= keyVersion, '角色 epoch 不得小于 keyVersion,实际 ' + role.epoch + ' vs ' + keyVersion);
218
+ });
219
+
@@ -0,0 +1,122 @@
1
+ /**
2
+ * v0.7.0 S3 补充 · 受限的启动竞选(priority-takeover)。
3
+ *
4
+ * 契约 §4 是「epoch 高者胜,同 epoch 才比 priority」——优先级不能跨 epoch 夺权,
5
+ * 于是优先节点重启期间被别人抢到更高 epoch 后会永久当 follower。这里给它一次有边界的重试权:
6
+ * 配置上没有 group.hub 的本机,启动稳定 2×heartbeatMs 后、优先级**严格更高**时尝试一次。
7
+ *
8
+ * 覆盖:①一次成功接管(epoch+1,原 leader 降级)②priority 相等不触发
9
+ * ③接管失败后推进 30s 也不重试 ④配了 group.hub 的成员不触发
10
+ */
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { setTimeout as sleep } from 'node:timers/promises';
14
+ import { roleOf, startCluster, waitFor } from './group-cluster.mjs';
15
+
16
+ const takeoverOf = (node) => node.server.group.takeover();
17
+
18
+ const electionLines = (lines, kind) => lines
19
+ .filter((line) => line.includes('[group] election ' + kind) && line.includes('reason=priority-takeover'));
20
+
21
+ test('S3 启动接管:高优先节点一次接管成功(epoch+1),原 leader 降级', async (t) => {
22
+ const lines = [];
23
+ const cluster = await startCluster(t, {
24
+ count: 2,
25
+ // 两台都是 hub 角色(配置上没有 group.hub),才有接管资格
26
+ hubRoles: [0, 1],
27
+ // 低优先(10)先当 leader;高优先(0)以 follower 起步
28
+ priorities: [10, 0],
29
+ followerOf: { 1: 0 },
30
+ logger: (line) => lines.push(String(line)),
31
+ });
32
+ const [low, high] = cluster.nodes;
33
+ assert.equal(roleOf(low).role, 'leader');
34
+ assert.equal(roleOf(high).role, 'follower');
35
+ assert.equal(roleOf(high).leaderPeerId, low.peerId);
36
+ assert.equal(takeoverOf(high).attempts, 0, '刚开始还不该动手');
37
+
38
+ await waitFor(() => takeoverOf(high).attempts === 1, { timeoutMs: 5_000 });
39
+ await waitFor(() => roleOf(high).role === 'leader', { timeoutMs: 5_000 });
40
+ await waitFor(() => roleOf(low).leaderPeerId === high.peerId, { timeoutMs: 5_000 });
41
+
42
+ assert.equal(roleOf(high).epoch, 2, '接管必须抬 epoch');
43
+ assert.equal(roleOf(low).role, 'follower');
44
+ assert.equal(roleOf(low).epoch, 2);
45
+ assert.equal(takeoverOf(high).attempts, 1);
46
+ assert.equal(takeoverOf(high).attempted, true);
47
+
48
+ const text = lines.join('\n');
49
+ assert.match(text, /\[group\] takeover attempt epoch=2 priority=0 leaderPriority=10/);
50
+ assert.equal(electionLines(lines, 'won').length, 1, '一次成功接管要留一行带票数的日志');
51
+ assert.match(text, /\[group\] takeover won epoch=2 votes=2\/2/);
52
+
53
+ // 稳定后不再重试:推进到远超过稳定期,attempts 仍然是 1
54
+ await sleep(400);
55
+ assert.equal(takeoverOf(high).attempts, 1);
56
+ assert.equal(electionLines(lines, 'won').length, 1);
57
+ });
58
+
59
+ test('S3 启动接管:priority 相等时一律不触发', async (t) => {
60
+ const cluster = await startCluster(t, {
61
+ count: 2,
62
+ hubRoles: [0, 1],
63
+ priorities: [0, 0],
64
+ followerOf: { 1: 0 },
65
+ });
66
+ const [low, high] = cluster.nodes;
67
+ // 等过稳定期(2×heartbeat=240ms)再多等几轮 tick
68
+ await sleep(700);
69
+ assert.equal(takeoverOf(high).attempts, 0, '优先级相等不得夺权');
70
+ assert.equal(takeoverOf(high).attempted, false);
71
+ assert.equal(roleOf(high).role, 'follower');
72
+ assert.equal(roleOf(high).leaderPeerId, low.peerId);
73
+ assert.equal(roleOf(low).role, 'leader');
74
+ });
75
+
76
+ test('S3 启动接管:失败一次后不再重试(假时钟推进 30s,接管仍只有 1 次)', async (t) => {
77
+ let fake = Date.now();
78
+ const clock = () => fake;
79
+ const lines = [];
80
+ const cluster = await startCluster(t, {
81
+ count: 2,
82
+ hubRoles: [0, 1],
83
+ priorities: [10, 0],
84
+ followerOf: { 1: 0 },
85
+ // 心跳放慢,好让「稳定期」在假时钟里可控(租约 = 3×400 = 1200ms)
86
+ heartbeatMs: 400,
87
+ tickMs: 25,
88
+ now: clock,
89
+ logger: (line) => lines.push(String(line)),
90
+ });
91
+ const [low, high] = cluster.nodes;
92
+ // 让 low 的心跳把「leader 优先级=10」教给 high(假时钟下真实时间照样走几轮)
93
+ await sleep(600);
94
+ assert.equal(takeoverOf(high).attempts, 0, '还没到稳定期(2×400=800ms)');
95
+
96
+ // 把 leader 打死,再跨过稳定期:这一枪注定落空
97
+ await low.server.close();
98
+ fake += 900;
99
+ await waitFor(() => takeoverOf(high).attempts === 1, { timeoutMs: 3_000 });
100
+ assert.equal(roleOf(high).role, 'follower', '失败后不得成为 leader');
101
+ assert.equal(electionLines(lines, 'lost').length, 1);
102
+ assert.match(lines.join('\n'), /\[group\] takeover lost epoch=2 votes=1\/2/);
103
+
104
+ // 推进 30s:普通租约过期选举可以重试,但**接管**必须一次就停
105
+ fake += 30_000;
106
+ await sleep(400);
107
+ assert.equal(takeoverOf(high).attempts, 1, '接管每进程只允许一次');
108
+ assert.equal(electionLines(lines, 'lost').length, 1);
109
+ assert.equal(electionLines(lines, 'won').length, 0);
110
+ });
111
+
112
+ test('S3 启动接管:配了 group.hub 的成员节点不触发(即使优先级更高)', async (t) => {
113
+ const cluster = await startCluster(t, { count: 2, priorities: [10, 0] });
114
+ const [low, member] = cluster.nodes;
115
+ assert.equal(low.server.group.isHub, true);
116
+ assert.equal(member.server.group.isHub, false);
117
+ await sleep(700);
118
+ assert.equal(takeoverOf(member).attempts, 0, '成员节点(配置了 group.hub)不得夺权');
119
+ assert.equal(roleOf(member).role, 'follower');
120
+ assert.equal(roleOf(low).role, 'leader');
121
+ });
122
+
@@ -278,7 +278,8 @@ test('§4 hub 首次初始化:group.json 0600、hub 自己是成员、管理
278
278
  const raw = JSON.parse(fs.readFileSync(groupFile, 'utf8'));
279
279
  // S3 §5:group.json 追加 pending(未提交区,与 messages 分开存)
280
280
  assert.deepEqual(Object.keys(raw).sort(), [
281
- 'createdAt', 'groupId', 'key', 'keyVersion', 'lastSeq', 'members', 'messages', 'name', 'pending',
281
+ // blocked:被移除的成员(自助 join 不得把 ta 加回来)
282
+ 'blocked', 'createdAt', 'groupId', 'key', 'keyVersion', 'lastSeq', 'members', 'messages', 'name', 'pending',
282
283
  ]);
283
284
  assert.equal(Buffer.from(raw.key, 'base64url').length, 32);
284
285
  assert.equal(raw.name, 'team');