@proteinjs/user-server 1.22.2 → 1.22.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/authentication/SocketIOSessionWatcher.d.ts +5 -0
  5. package/dist/src/authentication/SocketIOSessionWatcher.d.ts.map +1 -1
  6. package/dist/src/authentication/SocketIOSessionWatcher.js +13 -2
  7. package/dist/src/authentication/SocketIOSessionWatcher.js.map +1 -1
  8. package/dist/src/authentication/UserStatusTableWatcher.d.ts.map +1 -1
  9. package/dist/src/authentication/UserStatusTableWatcher.js +2 -1
  10. package/dist/src/authentication/UserStatusTableWatcher.js.map +1 -1
  11. package/dist/src/authentication/authenticate.js +2 -2
  12. package/dist/src/authentication/authenticate.js.map +1 -1
  13. package/dist/src/authorization/userCache.d.ts.map +1 -1
  14. package/dist/src/authorization/userCache.js +3 -2
  15. package/dist/src/authorization/userCache.js.map +1 -1
  16. package/dist/src/routes/devLogin.d.ts.map +1 -1
  17. package/dist/src/routes/devLogin.js +29 -14
  18. package/dist/src/routes/devLogin.js.map +1 -1
  19. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  20. package/dist/src/routes/executePasswordReset.js +7 -6
  21. package/dist/src/routes/executePasswordReset.js.map +1 -1
  22. package/dist/src/routes/initiatePasswordReset.js +2 -2
  23. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  24. package/dist/src/routes/signup.d.ts.map +1 -1
  25. package/dist/src/routes/signup.js +5 -2
  26. package/dist/src/routes/signup.js.map +1 -1
  27. package/dist/src/routes/validateResetPasswordToken.d.ts.map +1 -1
  28. package/dist/src/routes/validateResetPasswordToken.js +5 -1
  29. package/dist/src/routes/validateResetPasswordToken.js.map +1 -1
  30. package/dist/src/services/MachineCredentials.d.ts +10 -1
  31. package/dist/src/services/MachineCredentials.d.ts.map +1 -1
  32. package/dist/src/services/MachineCredentials.js +46 -15
  33. package/dist/src/services/MachineCredentials.js.map +1 -1
  34. package/dist/src/services/Roles.d.ts.map +1 -1
  35. package/dist/src/services/Roles.js +6 -2
  36. package/dist/src/services/Roles.js.map +1 -1
  37. package/dist/src/services/Signup.d.ts +15 -0
  38. package/dist/src/services/Signup.d.ts.map +1 -1
  39. package/dist/src/services/Signup.js +79 -27
  40. package/dist/src/services/Signup.js.map +1 -1
  41. package/dist/test/DevLogin.test.js +68 -0
  42. package/dist/test/DevLogin.test.js.map +1 -1
  43. package/dist/test/LogCapture.d.ts +10 -0
  44. package/dist/test/LogCapture.d.ts.map +1 -1
  45. package/dist/test/LogCapture.js +19 -0
  46. package/dist/test/LogCapture.js.map +1 -1
  47. package/dist/test/LogCapture.test.d.ts +2 -0
  48. package/dist/test/LogCapture.test.d.ts.map +1 -0
  49. package/dist/test/LogCapture.test.js +86 -0
  50. package/dist/test/LogCapture.test.js.map +1 -0
  51. package/dist/test/MachineAccounts.integration.test.js +156 -28
  52. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  53. package/dist/test/NoAddressesInLogs.integration.test.d.ts +2 -0
  54. package/dist/test/NoAddressesInLogs.integration.test.d.ts.map +1 -0
  55. package/dist/test/NoAddressesInLogs.integration.test.js +719 -0
  56. package/dist/test/NoAddressesInLogs.integration.test.js.map +1 -0
  57. package/dist/test/SignupRace.integration.test.d.ts +2 -0
  58. package/dist/test/SignupRace.integration.test.d.ts.map +1 -0
  59. package/dist/test/SignupRace.integration.test.js +320 -0
  60. package/dist/test/SignupRace.integration.test.js.map +1 -0
  61. package/dist/test/SignupRoute.test.js +80 -1
  62. package/dist/test/SignupRoute.test.js.map +1 -1
  63. package/dist/test/SocketIOSessionWatcher.test.d.ts +2 -0
  64. package/dist/test/SocketIOSessionWatcher.test.d.ts.map +1 -0
  65. package/dist/test/SocketIOSessionWatcher.test.js +144 -0
  66. package/dist/test/SocketIOSessionWatcher.test.js.map +1 -0
  67. package/generated/index.ts +1 -1
  68. package/package.json +6 -5
  69. package/src/authentication/SocketIOSessionWatcher.ts +13 -1
  70. package/src/authentication/UserStatusTableWatcher.ts +2 -1
  71. package/src/authentication/authenticate.ts +1 -1
  72. package/src/authorization/userCache.ts +3 -2
  73. package/src/routes/devLogin.ts +22 -10
  74. package/src/routes/executePasswordReset.ts +6 -5
  75. package/src/routes/initiatePasswordReset.ts +1 -1
  76. package/src/routes/signup.ts +5 -2
  77. package/src/routes/validateResetPasswordToken.ts +5 -1
  78. package/src/services/MachineCredentials.ts +44 -9
  79. package/src/services/Roles.ts +6 -2
  80. package/src/services/Signup.ts +60 -15
  81. package/test/DevLogin.test.ts +35 -1
  82. package/test/LogCapture.test.ts +29 -0
  83. package/test/LogCapture.ts +16 -0
  84. package/test/MachineAccounts.integration.test.ts +97 -22
  85. package/test/NoAddressesInLogs.integration.test.ts +410 -0
  86. package/test/SignupRace.integration.test.ts +230 -0
  87. package/test/SignupRoute.test.ts +54 -1
  88. package/test/SocketIOSessionWatcher.test.ts +86 -0
@@ -0,0 +1,230 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { MailSink, MailSinkRecord } from '@proteinjs/email-server';
4
+ import { SourceRepository } from '@proteinjs/reflection';
5
+ import { tables } from '@proteinjs/user';
6
+ import { PasswordHasher } from '../src/authentication/PasswordHasher';
7
+ import { signup } from '../src/routes/signup';
8
+ import { Signup } from '../src/services/Signup';
9
+ import { RequestDigests } from '../src/throttle/RequestDigests';
10
+ import { LogCapture } from './LogCapture';
11
+ import { createPassportRequest } from './passportSessionHarness';
12
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
13
+
14
+ /**
15
+ * Two sign-ups for one address at the same moment. The signup door has one rule for an address
16
+ * that already has an account: the caller gets the response a new account gets, byte for byte, no
17
+ * session is minted, the address's owner is told by mail, and no log line names the address. A
18
+ * race used to break it: both requests passed the existence check before either wrote, the unique
19
+ * index on the address refused the second insert, and the database's sentence — which names the
20
+ * address — came back as the loser's response and its log line.
21
+ *
22
+ * Driven end to end against the Spanner emulator: the real route, the real passport session
23
+ * machinery, the real mail sink (`EMAIL_TRANSPORT=sink`), the log captured as the default writer
24
+ * emits it. Each create's password hash is held until both creates have arrived, so both have
25
+ * passed the existence check and both inserts race.
26
+ */
27
+
28
+ const testEnv = new UserServerTestEnvironment();
29
+
30
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
31
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
32
+
33
+ const CONFIG_KEYS = [
34
+ '@proteinjs/email-server/DefaultEmailConfigFactory',
35
+ '@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory',
36
+ '@proteinjs/user-server/DefaultInviteConfigFactory',
37
+ ];
38
+
39
+ const WELCOME_SUBJECT = 'Welcome';
40
+ const EXISTS_SUBJECT = 'Account already exists';
41
+
42
+ type SignupOutcome = {
43
+ status: number;
44
+ /** The body as the wire carries it (express serializes an object body as JSON). */
45
+ bytes: string;
46
+ loggedInAs?: string;
47
+ sessionEvents: string[];
48
+ };
49
+
50
+ /** One sign-up through the route, the session read back off the request as passport left it. */
51
+ const signUp = async (body: Record<string, unknown>): Promise<SignupOutcome> => {
52
+ const { request, events } = await createPassportRequest({ body });
53
+ const outcome: SignupOutcome = { status: 200, bytes: '', sessionEvents: events };
54
+ const response = {
55
+ status(code: number) {
56
+ outcome.status = code;
57
+ return this;
58
+ },
59
+ send(sent?: unknown) {
60
+ outcome.bytes = JSON.stringify(sent);
61
+ },
62
+ };
63
+ await signup.onRequest(request as never, response as never);
64
+ outcome.loggedInAs = request.session.passport?.user;
65
+ return outcome;
66
+ };
67
+
68
+ /**
69
+ * Runs `run` with every account creation's password hash held until `count` hashes have started —
70
+ * each create has passed the existence check by then, so their inserts race.
71
+ */
72
+ const withCreatesHeldTogether = async <T>(count: number, run: () => Promise<T>): Promise<T> => {
73
+ const hash = PasswordHasher.prototype.hash;
74
+ let arrived = 0;
75
+ let release!: () => void;
76
+ const allArrived = new Promise<void>((resolve) => (release = resolve));
77
+ const held = jest.spyOn(PasswordHasher.prototype, 'hash').mockImplementation(async function (
78
+ this: PasswordHasher,
79
+ password: string
80
+ ) {
81
+ const hashed = await hash.call(this, password);
82
+ if (++arrived === count) {
83
+ release();
84
+ }
85
+ await allArrived;
86
+ return hashed;
87
+ });
88
+ try {
89
+ return await run();
90
+ } finally {
91
+ held.mockRestore();
92
+ }
93
+ };
94
+
95
+ /** The mail the sink holds for `address`, oldest first. */
96
+ const mailTo = (address: string): MailSinkRecord[] =>
97
+ MailSink.get()
98
+ .list()
99
+ .filter((record) => record.to.includes(address))
100
+ .reverse();
101
+
102
+ describe('two sign-ups for one address at once', () => {
103
+ let originalTransport: string | undefined;
104
+
105
+ beforeAll(async () => {
106
+ await testEnv.beforeAll();
107
+ originalTransport = process.env.EMAIL_TRANSPORT;
108
+ process.env.EMAIL_TRANSPORT = 'sink';
109
+ const cache = objectCache();
110
+ cache['@proteinjs/email-server/DefaultEmailConfigFactory'] = [
111
+ {
112
+ getEmailConfig: () => ({
113
+ host: 'smtp.test.local',
114
+ port: 465,
115
+ secure: true,
116
+ from: '"Example" <hi@example.com>',
117
+ }),
118
+ },
119
+ ];
120
+ cache['@proteinjs/email-server/DefaultSignupConfirmationEmailConfigFactory'] = [
121
+ {
122
+ getConfig: () => ({
123
+ newUserSubject: WELCOME_SUBJECT,
124
+ existingUserSubject: EXISTS_SUBJECT,
125
+ getNewUserEmailContent: () => ({ text: 'welcome' }),
126
+ getExistingUserEmailContent: () => ({ text: 'someone tried to sign up with your address' }),
127
+ }),
128
+ },
129
+ ];
130
+ // Invite-optional, the library default — the suites load src, never the generated source graph.
131
+ cache['@proteinjs/user-server/DefaultInviteConfigFactory'] = [{ getConfig: () => ({ isInviteOnly: false }) }];
132
+ }, 120000);
133
+
134
+ afterAll(async () => {
135
+ const cache = objectCache();
136
+ for (const key of CONFIG_KEYS) {
137
+ delete cache[key];
138
+ }
139
+ if (originalTransport === undefined) {
140
+ delete process.env.EMAIL_TRANSPORT;
141
+ } else {
142
+ process.env.EMAIL_TRANSPORT = originalTransport;
143
+ }
144
+ await testEnv.afterAll();
145
+ });
146
+
147
+ beforeEach(() => {
148
+ MailSink.get().clear();
149
+ });
150
+
151
+ it('the loser answers exactly as a plain existing address does: the same bytes, no session, one mail to the owner, no address in the log', async () => {
152
+ // Typed with capitals, stored lowercased — the address a person types.
153
+ const local = `race-${randomBytes(4).toString('hex')}`;
154
+ const address = `${local}@test.local`;
155
+ const body = { name: 'Racer', email: `${local.toUpperCase()}@Test.local`, password: 'a-password' };
156
+
157
+ let raced: SignupOutcome[] = [];
158
+ const log = await LogCapture.during(async () => {
159
+ raced = await withCreatesHeldTogether(2, () => Promise.all([signUp(body), signUp(body)]));
160
+ });
161
+ const racedMail = mailTo(address);
162
+
163
+ // A plain sign-up for the address that now exists: the response the rule promises.
164
+ const plain = await signUp(body);
165
+ expect(plain.loggedInAs).toBeUndefined();
166
+
167
+ // Both racers got that response, byte for byte.
168
+ expect(raced.map((outcome) => [outcome.status, outcome.bytes])).toEqual([
169
+ [plain.status, plain.bytes],
170
+ [plain.status, plain.bytes],
171
+ ]);
172
+ // One account; one session, for the racer that created it; the loser none at all.
173
+ expect((await getDbAsSystem().query(tables.User, { email: address })).length).toBe(1);
174
+ expect(raced.filter((outcome) => outcome.loggedInAs === address)).toHaveLength(1);
175
+ const loser = raced.find((outcome) => outcome.loggedInAs === undefined);
176
+ expect(loser?.sessionEvents).toEqual([]);
177
+ // The owner's mail: the winner's welcome and ONE notice that someone tried the address again.
178
+ expect(racedMail.map((record) => record.subject).sort()).toEqual([EXISTS_SUBJECT, WELCOME_SUBJECT].sort());
179
+
180
+ // No line this process wrote names the address (its local part, in any case or encoding) except the database
181
+ // driver's own line for the refused statement: the driver's version (the one a package lock
182
+ // pins) decides whether that line prints bound values; it is the driver's to keep clean.
183
+ const lines = log.lines.filter((line) => !line.includes('[SpannerDriver]'));
184
+ expect(lines.length).toBeGreaterThan(0);
185
+ expect(lines.filter((line) => line.toLowerCase().includes(local))).toEqual([]);
186
+ // The account lines name the account by its digest: the winner's `Created user` and the
187
+ // loser's `already exists`, each carrying the same digest for the one address.
188
+ const digest = new RequestDigests().account(address);
189
+ expect(lines.filter((line) => line.includes('Created user') && line.includes(digest))).toHaveLength(1);
190
+ expect(lines.filter((line) => line.includes('already exists') && line.includes(digest))).toHaveLength(1);
191
+ });
192
+
193
+ it('a create that loses the race to the same address reports it as existing, the account row the winner wrote untouched', async () => {
194
+ const local = `race-${randomBytes(4).toString('hex')}`;
195
+ const address = `${local}@test.local`;
196
+ // Both creates carry the address as typed (capitals): the row is stored lowercased, and the
197
+ // loser's re-read must ask for the address the way the row holds it.
198
+ const account = (name: string) => ({
199
+ name,
200
+ email: `${local.toUpperCase()}@Test.local`,
201
+ password: 'a-password',
202
+ emailVerified: false,
203
+ invitedBy: null,
204
+ });
205
+
206
+ const outcomes = await withCreatesHeldTogether(2, () =>
207
+ Promise.all([new Signup().createAccount(account('First')), new Signup().createAccount(account('Second'))])
208
+ );
209
+
210
+ expect(outcomes.slice().sort()).toEqual(['created', 'exists']);
211
+ const rows = await getDbAsSystem().query(tables.User, { email: address });
212
+ expect(rows).toHaveLength(1);
213
+ expect(rows[0].name).toBe(outcomes[0] === 'created' ? 'First' : 'Second');
214
+ });
215
+
216
+ it('an insert the database refuses while the address has no account is a real failure: it throws, never "exists"', async () => {
217
+ const address = `race-${randomBytes(4).toString('hex')}@test.local`;
218
+ // The name column holds 255 characters; the database refuses a longer one.
219
+ const create = new Signup().createAccount({
220
+ name: 'n'.repeat(300),
221
+ email: address,
222
+ password: 'a-password',
223
+ emailVerified: false,
224
+ invitedBy: null,
225
+ });
226
+
227
+ await expect(create).rejects.toThrow();
228
+ expect(await getDbAsSystem().query(tables.User, { email: address })).toHaveLength(0);
229
+ });
230
+ });
@@ -35,7 +35,7 @@ jest.mock('@proteinjs/email-server', () => ({
35
35
 
36
36
  import { getDbAsSystem, Reference } from '@proteinjs/db';
37
37
  import { SourceRepository } from '@proteinjs/reflection';
38
- import { tables } from '@proteinjs/user';
38
+ import { MachineAccount, tables } from '@proteinjs/user';
39
39
  import { signup } from '../src/routes/signup';
40
40
  import { createPassportRequest } from './passportSessionHarness';
41
41
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
@@ -45,6 +45,31 @@ const testEnv = new UserServerTestEnvironment();
45
45
  const INVITE_CONFIG_KEY = '@proteinjs/user-server/DefaultInviteConfigFactory';
46
46
 
47
47
  type ObjectCache = { objectCache: Record<string, unknown[]> };
48
+ type NamedObjectCache = {
49
+ namedObjectCache: Record<string, { qualifiedName: string; packageName: string; object: unknown }[]>;
50
+ };
51
+
52
+ /** A code-declared machine account owning an address (the declaration only — never booted here). */
53
+ class DeclaredMachineAccount extends MachineAccount {
54
+ id = 'declared-machine';
55
+ email = 'declared.machine@test.local';
56
+ accountName = 'Declared machine';
57
+ roles = [];
58
+ secretName = 'declared-machine-secret';
59
+ }
60
+
61
+ const LOADER_KEY = '@proteinjs/db/SourceRecordLoader';
62
+
63
+ /** Seed the build's source-record declarations (the machine-account read resolves them by name). */
64
+ const setDeclarations = (declarations: unknown[]) => {
65
+ (SourceRepository.get() as unknown as NamedObjectCache).namedObjectCache[LOADER_KEY] = declarations.map(
66
+ (object, i) => ({
67
+ qualifiedName: `@proteinjs/user-server-test/Declared${i}`,
68
+ packageName: '@test/declarer',
69
+ object,
70
+ })
71
+ );
72
+ };
48
73
 
49
74
  /** Seed the invite-config lookup (tests don't load the generated source graph). */
50
75
  const setInviteConfig = (factories: unknown[]) => {
@@ -94,12 +119,14 @@ describe('signup route — auto-login after signup', () => {
94
119
  }, 120000);
95
120
 
96
121
  afterAll(async () => {
122
+ delete (SourceRepository.get() as unknown as NamedObjectCache).namedObjectCache[LOADER_KEY];
97
123
  await testEnv.afterAll();
98
124
  });
99
125
 
100
126
  beforeEach(async () => {
101
127
  jest.clearAllMocks();
102
128
  setInviteConfig([]);
129
+ setDeclarations([]);
103
130
  const db = getDbAsSystem();
104
131
  await db.delete(tables.Invite, {});
105
132
  await db.delete(tables.User, {});
@@ -180,6 +207,32 @@ describe('signup route — auto-login after signup', () => {
180
207
  expect(sendEmail).toHaveBeenCalledTimes(1); // "account already exists" email
181
208
  });
182
209
 
210
+ it(`an address a machine-account declaration owns can't be registered — by signup or by invite: plain words, no account, no session`, async () => {
211
+ setDeclarations([new DeclaredMachineAccount()]);
212
+
213
+ const direct = await invokeSignup({
214
+ name: 'Squatter',
215
+ email: 'Declared.Machine@test.local',
216
+ password: 'pw-squat-1',
217
+ });
218
+ expect(direct.body).toEqual({ error: "This address can't be registered." });
219
+ expect(direct.loggedInAs).toBeUndefined();
220
+ expect(direct.sessionEvents).toEqual([]);
221
+
222
+ await getDbAsSystem().insert(tables.Invite, {
223
+ email: 'declared.machine@test.local',
224
+ token: 'invite-token-declared',
225
+ tokenExpiresAt: moment().add(1, 'day'),
226
+ invitedBy: new Reference(tables.User.name, 'inviter-1'),
227
+ });
228
+ const invited = await invokeSignup({ name: 'Squatter', password: 'pw-squat-2', token: 'invite-token-declared' });
229
+ expect(invited.body).toEqual({ error: "This address can't be registered." });
230
+ expect(invited.loggedInAs).toBeUndefined();
231
+
232
+ expect(await getUserRow('declared.machine@test.local')).toBeUndefined();
233
+ expect(sendEmail).not.toHaveBeenCalled();
234
+ });
235
+
183
236
  it('an unknown invite token gets the honest error — no session, no account', async () => {
184
237
  const outcome = await invokeSignup({
185
238
  name: 'Token Guess',
@@ -0,0 +1,86 @@
1
+ import { createServer } from 'http';
2
+ import { QueryBuilder } from '@proteinjs/db';
3
+ import { SourceRepository } from '@proteinjs/reflection';
4
+ import { SocketIOServerRepo } from '@proteinjs/server';
5
+ import { Session } from '@proteinjs/user';
6
+ import { SocketIOSessionWatcher } from '../src/authentication/SocketIOSessionWatcher';
7
+
8
+ type ObjectCache = { objectCache: Record<string, unknown[]> };
9
+
10
+ /** What the watcher asked the socket server to do: which rooms (session ids) it closed. */
11
+ type DisconnectCall = { rooms: string[]; close: boolean };
12
+
13
+ /**
14
+ * The socket server this file installs through the repo's own factory seam — a stand-in that
15
+ * records every `in(rooms).disconnectSockets(close)` it receives (no socket.io client is part of
16
+ * this package, so no real socket can connect to observe).
17
+ */
18
+ class RecordingSocketIOServer {
19
+ readonly calls: DisconnectCall[] = [];
20
+
21
+ in(rooms: string | string[]) {
22
+ return {
23
+ disconnectSockets: (close: boolean) => {
24
+ this.calls.push({ rooms: ([] as string[]).concat(rooms), close });
25
+ },
26
+ };
27
+ }
28
+ }
29
+
30
+ const session = (sessionId: string): Session =>
31
+ ({ id: `row-${sessionId}`, sessionId, session: '{}', userEmail: 'someone@test.local' }) as unknown as Session;
32
+
33
+ const deleteSessions = async (sessions: Session[]) =>
34
+ await new SocketIOSessionWatcher().afterDelete(
35
+ sessions.length,
36
+ sessions,
37
+ new QueryBuilder<Session>('session'),
38
+ new QueryBuilder<Session>('session')
39
+ );
40
+
41
+ /**
42
+ * Deleting session rows closes their sockets — and a deletion is not always made by a running
43
+ * server: a boot's source-record sync (and a migration Job, which never starts one) deactivates a
44
+ * withdrawn machine account and deletes its sessions before any socket server exists.
45
+ */
46
+ describe('SocketIOSessionWatcher', () => {
47
+ it('a deletion before the socket server exists resolves — no socket can be open yet', async () => {
48
+ expect(SocketIOServerRepo.getSocketIOServerIfExists()).toBeUndefined();
49
+
50
+ await expect(deleteSessions([session('before-boot')])).resolves.toBeUndefined();
51
+ });
52
+
53
+ describe('with the socket server up', () => {
54
+ const server = new RecordingSocketIOServer();
55
+
56
+ beforeAll(async () => {
57
+ (SourceRepository.get() as unknown as ObjectCache).objectCache['@proteinjs/event/DefaultSocketIOServerFactory'] =
58
+ [{ createSocketIOServer: async () => server }];
59
+ await SocketIOServerRepo.createSocketIOServer(createServer());
60
+ });
61
+
62
+ afterAll(() => {
63
+ delete (SourceRepository.get() as unknown as ObjectCache).objectCache[
64
+ '@proteinjs/event/DefaultSocketIOServerFactory'
65
+ ];
66
+ });
67
+
68
+ beforeEach(() => {
69
+ server.calls.length = 0;
70
+ });
71
+
72
+ it(`closes exactly the deleted sessions' sockets`, async () => {
73
+ await deleteSessions([session('s-1'), session('s-2')]);
74
+
75
+ expect(server.calls).toEqual([{ rooms: ['s-1', 's-2'], close: true }]);
76
+ });
77
+
78
+ it('a deletion with no session ids closes nothing — an empty room list would address every socket', async () => {
79
+ // socket.io's in-memory adapter applies an operation with NO rooms to every socket on the
80
+ // namespace (`apply`: `if (rooms.size) … else for every sid`), so `in([])` must never be sent.
81
+ await deleteSessions([]);
82
+
83
+ expect(server.calls).toEqual([]);
84
+ });
85
+ });
86
+ });