|
1 | 1 | const test = require('node:test'); |
2 | 2 | const assert = require('node:assert'); |
3 | | -const { startChannelMs, op, headers } = require('./helper'); |
| 3 | +const { startChannelMs, decodeServerEvent, mintOpId, op, headers } = require('./helper'); |
4 | 4 |
|
5 | 5 | const ALICE = 'a3f2e8c5d'; |
6 | 6 | const BOB = 'b1c2d3e4f'; |
@@ -309,3 +309,149 @@ test('REST and WS share one sequence space per (user, resource)', async () => { |
309 | 309 | assert.strictEqual(response.statusCode, 200); |
310 | 310 | assert.strictEqual(Number(await memCache.get(`seq:${ALICE}:${DM}`)), 41); |
311 | 311 | }); |
| 312 | + |
| 313 | +// ---- roles, owner leave and succession (DECISIONS rows 9 / 80) ------------ |
| 314 | + |
| 315 | +const ROLES = '01efabcd-7000-8000-8abc-000000000100'; |
| 316 | +const HANDOVER = '01efabcd-7000-8000-8abc-000000000101'; |
| 317 | +const SENIORITY = '01efabcd-7000-8000-8abc-000000000102'; |
| 318 | +const LAST = '01efabcd-7000-8000-8abc-000000000103'; |
| 319 | +const STRANGER = 'e5f6a7b8c'; |
| 320 | + |
| 321 | +/** `op` with a unique op_id, so same-seq writes on other channels cannot collide. */ |
| 322 | +let nonce = 0; |
| 323 | +function uop(userId, seq, extra = {}) { |
| 324 | + nonce += 1; |
| 325 | + return { ...op(userId, seq, extra), op_id: mintOpId(userId, { counter: seq, rand: nonce }) }; |
| 326 | +} |
| 327 | + |
| 328 | +const newGroup = (channelId, creator, members) => harness.inject({ |
| 329 | + method: 'POST', |
| 330 | + url: '/', |
| 331 | + headers: headers(creator), |
| 332 | + payload: uop(creator, 1, { channel_id: channelId, kind: 'group', name: 'Roles', members }) |
| 333 | +}); |
| 334 | + |
| 335 | +const setRole = (channelId, actor, target, role, seq) => harness.inject({ |
| 336 | + method: 'PATCH', |
| 337 | + url: `/${channelId}/members/${target}`, |
| 338 | + headers: headers(actor), |
| 339 | + payload: uop(actor, seq, { role }) |
| 340 | +}); |
| 341 | + |
| 342 | +const leaveChannel = (channelId, actor, seq) => harness.inject({ |
| 343 | + method: 'DELETE', |
| 344 | + url: `/${channelId}/members/${actor}`, |
| 345 | + headers: headers(actor), |
| 346 | + payload: uop(actor, seq) |
| 347 | +}); |
| 348 | + |
| 349 | +const roles = async (channelId) => { |
| 350 | + const info = await harness.inject({ method: 'GET', url: `/_internal/${channelId}` }); |
| 351 | + return Object.fromEntries(body(info).members.map((m) => [m.user_id, m.role])); |
| 352 | +}; |
| 353 | + |
| 354 | +test('the owner promotes a member to admin and re-announces them', async () => { |
| 355 | + await newGroup(ROLES, ALICE, [ALICE, BOB, CARL, DAVE]); |
| 356 | + const response = await setRole(ROLES, ALICE, BOB, 'admin', 2); |
| 357 | + assert.strictEqual(response.statusCode, 200); |
| 358 | + assert.deepStrictEqual(body(response), { user_id: BOB, role: 'admin' }); |
| 359 | + |
| 360 | + const { recipients, body: event } = harness.lastEvent(); |
| 361 | + assert.strictEqual(event.type, 'CHANNEL_MEMBER_ADDED'); |
| 362 | + assert.deepStrictEqual(event.memberAdded.members, [BOB]); |
| 363 | + assert.strictEqual(event.memberAdded.role, 'admin'); |
| 364 | + assert.deepStrictEqual(recipients.sort(), [ALICE, BOB, CARL, DAVE].sort()); |
| 365 | + assert.strictEqual((await roles(ROLES))[BOB], 'admin'); |
| 366 | +}); |
| 367 | + |
| 368 | +test('an admin may promote another member', async () => { |
| 369 | + const response = await setRole(ROLES, BOB, CARL, 'admin', 1); |
| 370 | + assert.strictEqual(response.statusCode, 200); |
| 371 | + assert.strictEqual((await roles(ROLES))[CARL], 'admin'); |
| 372 | +}); |
| 373 | + |
| 374 | +test('a plain member cannot change roles', async () => { |
| 375 | + const response = await setRole(ROLES, DAVE, CARL, 'member', 1); |
| 376 | + assert.strictEqual(response.statusCode, 403); |
| 377 | + assert.strictEqual(body(response).error.code, 'forbidden'); |
| 378 | +}); |
| 379 | + |
| 380 | +test('nobody demotes themselves, and the owner role cannot be changed', async () => { |
| 381 | + const self = await setRole(ROLES, BOB, BOB, 'member', 2); |
| 382 | + assert.strictEqual(self.statusCode, 403); |
| 383 | + const owner = await setRole(ROLES, BOB, ALICE, 'member', 2); |
| 384 | + assert.strictEqual(owner.statusCode, 403); |
| 385 | + assert.strictEqual((await roles(ROLES))[ALICE], 'owner'); |
| 386 | +}); |
| 387 | + |
| 388 | +test('a role change on a non member is not_found', async () => { |
| 389 | + const response = await setRole(ROLES, ALICE, STRANGER, 'admin', 3); |
| 390 | + assert.strictEqual(response.statusCode, 404); |
| 391 | + assert.strictEqual(body(response).error.code, 'not_found'); |
| 392 | +}); |
| 393 | + |
| 394 | +test('a replayed role PATCH returns the stored outcome without fanning out', async () => { |
| 395 | + const payload = uop(ALICE, 3, { role: 'admin' }); |
| 396 | + const request = { |
| 397 | + method: 'PATCH', url: `/${ROLES}/members/${DAVE}`, headers: headers(ALICE), payload |
| 398 | + }; |
| 399 | + const first = await harness.inject(request); |
| 400 | + const before = harness.published().length; |
| 401 | + const second = await harness.inject(request); |
| 402 | + assert.strictEqual(first.statusCode, 200); |
| 403 | + assert.deepStrictEqual(body(second), body(first)); |
| 404 | + assert.strictEqual(harness.published().length, before, 'replay must not fan out again'); |
| 405 | +}); |
| 406 | + |
| 407 | +test('an owner who leaves hands the channel to the longest-standing admin', async () => { |
| 408 | + await newGroup(HANDOVER, ALICE, [ALICE, BOB, CARL]); |
| 409 | + await setRole(HANDOVER, ALICE, CARL, 'admin', 2); |
| 410 | + |
| 411 | + const before = harness.published().length; |
| 412 | + const response = await leaveChannel(HANDOVER, ALICE, 3); |
| 413 | + assert.strictEqual(response.statusCode, 200); |
| 414 | + assert.strictEqual(body(response).member_count, 2); |
| 415 | + |
| 416 | + const fanned = harness.published().slice(before); |
| 417 | + assert.strictEqual(fanned.length, 2, 'removal + succession'); |
| 418 | + const [removed, promoted] = fanned; |
| 419 | + assert.strictEqual(decodeServerEvent(removed).memberRemoved.member, ALICE); |
| 420 | + assert.deepStrictEqual(decodeServerEvent(promoted).memberAdded.members, [CARL]); |
| 421 | + assert.strictEqual(decodeServerEvent(promoted).memberAdded.role, 'owner'); |
| 422 | + assert.deepStrictEqual(promoted.recipients.sort(), [BOB, CARL].sort()); |
| 423 | + assert.deepStrictEqual(await roles(HANDOVER), { [BOB]: 'member', [CARL]: 'owner' }); |
| 424 | +}); |
| 425 | + |
| 426 | +test('with no admin the longest-standing member inherits', async () => { |
| 427 | + await newGroup(SENIORITY, ALICE, [ALICE, DAVE]); |
| 428 | + await harness.inject({ |
| 429 | + method: 'POST', |
| 430 | + url: `/${SENIORITY}/members`, |
| 431 | + headers: headers(ALICE), |
| 432 | + payload: uop(ALICE, 2, { members: [BOB] }) |
| 433 | + }); |
| 434 | + |
| 435 | + const response = await leaveChannel(SENIORITY, ALICE, 3); |
| 436 | + assert.strictEqual(response.statusCode, 200); |
| 437 | + // DAVE joined at create, BOB later: seniority beats the lexical tiebreak |
| 438 | + assert.deepStrictEqual(await roles(SENIORITY), { [DAVE]: 'owner', [BOB]: 'member' }); |
| 439 | + assert.strictEqual(harness.lastEvent().body.memberAdded.role, 'owner'); |
| 440 | +}); |
| 441 | + |
| 442 | +test('the last member out hard deletes the channel', async () => { |
| 443 | + await newGroup(LAST, ALICE, [ALICE, BOB]); |
| 444 | + const bobLeaves = await leaveChannel(LAST, BOB, 1); |
| 445 | + assert.strictEqual(bobLeaves.statusCode, 200); |
| 446 | + |
| 447 | + const response = await leaveChannel(LAST, ALICE, 2); |
| 448 | + assert.strictEqual(response.statusCode, 200); |
| 449 | + assert.strictEqual(body(response).member_count, 0); |
| 450 | + |
| 451 | + const { recipients, body: event } = harness.lastEvent(); |
| 452 | + assert.strictEqual(event.type, 'CHANNEL_DELETED'); |
| 453 | + assert.deepStrictEqual(recipients, [ALICE]); |
| 454 | + |
| 455 | + const info = await harness.inject({ method: 'GET', url: `/_internal/${LAST}` }); |
| 456 | + assert.strictEqual(info.statusCode, 404); |
| 457 | +}); |
0 commit comments