diff --git a/src/js/channels/Membership.js b/src/js/channels/Membership.js index 5d247c8..ac5c156 100644 --- a/src/js/channels/Membership.js +++ b/src/js/channels/Membership.js @@ -247,6 +247,10 @@ export class Membership { // out of the candidate set and the Moderation list loses the // one entry it exists to show. ...(channel.knownBanned || []), + // A failed roster read must not drop anyone the last sweep saw, or the + // snapshot shrinks and a later loss of access never rotates. + ...(channel.accessSnapshot || []), + ...(channel.rotatedForNoAccess || channel.rotatedForBanned || []), ...epochKeyManager.getSeenRequesters(channel.messageStreamId), ...roster.map(m => m.account), ...onChain diff --git a/tests/unit/channels.extended.test.js b/tests/unit/channels.extended.test.js index 1515b4c..6675000 100644 --- a/tests/unit/channels.extended.test.js +++ b/tests/unit/channels.extended.test.js @@ -587,6 +587,19 @@ describe('ChannelManager Extended', () => { flags.mockRestore(); }); + it("a failed roster read keeps the last sweep's addresses among the gate candidates", async () => { + const { gateManager } = await import('../../src/js/gate.js'); + epochKeyManager.getRosterMembers.mockRejectedValueOnce(new Error('keys resend timed out')); + channel.accessSnapshot = ['0xmyaddress', '0xmember2']; + channel.rotatedForNoAccess = ['0xmember3']; + gateManager.getGateMembers.mockClear(); + + await channelManager.getGateMemberFlags(streamId); + + expect(gateManager.getGateMembers.mock.calls[0][1]) + .toEqual(expect.arrayContaining(['0xmember2', '0xmember3'])); + }); + it('a ban whose rotation goes out owes nothing', async () => { epochKeyManager.rotateEpoch.mockResolvedValue(undefined);