serverless-ircd 0.8.0 → 0.9.0
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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
|
@@ -139,6 +139,77 @@ describe('hostservReducer — dispatch', () => {
|
|
|
139
139
|
expect(sentTexts(out.effects).some((t) => t.includes('Unknown command'))).toBe(true);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it('hides oper-only commands from a non-oper on empty body', () => {
|
|
143
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
144
|
+
const conn = identifiedConn('bob');
|
|
145
|
+
const ctx = makeCtx(conn, services);
|
|
146
|
+
|
|
147
|
+
const out = hostservReducer(conn, privmsg(''), ctx);
|
|
148
|
+
|
|
149
|
+
const lines = sentTexts(out.effects);
|
|
150
|
+
expect(lines).toContain(
|
|
151
|
+
':HostServ!HostServ@services NOTICE bob :Available commands: ON, OFF, REQUEST',
|
|
152
|
+
);
|
|
153
|
+
const helpLine = lines.find((t) => t.includes('Available commands'));
|
|
154
|
+
for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
|
|
155
|
+
expect(helpLine).not.toContain(cmd);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('lists the full command set to an oper on empty body', () => {
|
|
160
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
161
|
+
const conn = operConn('admin');
|
|
162
|
+
const ctx = makeCtx(conn, services);
|
|
163
|
+
|
|
164
|
+
const out = hostservReducer(conn, privmsg(''), ctx);
|
|
165
|
+
|
|
166
|
+
expect(sentTexts(out.effects)).toContain(
|
|
167
|
+
':HostServ!HostServ@services NOTICE admin :Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('hides oper-only commands from a non-oper on an unknown subcommand', () => {
|
|
172
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
173
|
+
const conn = identifiedConn('bob');
|
|
174
|
+
const ctx = makeCtx(conn, services);
|
|
175
|
+
|
|
176
|
+
const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
|
|
177
|
+
|
|
178
|
+
const lines = sentTexts(out.effects);
|
|
179
|
+
expect(lines).toContain(
|
|
180
|
+
':HostServ!HostServ@services NOTICE bob :Unknown command. Available: ON, OFF, REQUEST',
|
|
181
|
+
);
|
|
182
|
+
const unknownLine = lines.find((t) => t.includes('Unknown command'));
|
|
183
|
+
for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
|
|
184
|
+
expect(unknownLine).not.toContain(cmd);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('lists the full command set to an oper on an unknown subcommand', () => {
|
|
189
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
190
|
+
const conn = operConn('admin');
|
|
191
|
+
const ctx = makeCtx(conn, services);
|
|
192
|
+
|
|
193
|
+
const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
|
|
194
|
+
|
|
195
|
+
expect(sentTexts(out.effects)).toContain(
|
|
196
|
+
':HostServ!HostServ@services NOTICE admin :Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('still rejects a non-oper who guesses an oper-only command (LIST)', () => {
|
|
201
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
202
|
+
services.requestVhost('alice', 'cool.example.net');
|
|
203
|
+
const conn = identifiedConn('bob');
|
|
204
|
+
const ctx = makeCtx(conn, services);
|
|
205
|
+
|
|
206
|
+
const out = hostservReducer(conn, privmsg('LIST'), ctx);
|
|
207
|
+
|
|
208
|
+
expect(sentTexts(out.effects)).toContain(
|
|
209
|
+
':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
142
213
|
it('updates lastSeen to ctx.clock.now()', () => {
|
|
143
214
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
144
215
|
const conn = identifiedConn('alice');
|
|
@@ -1064,6 +1064,83 @@ describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
|
|
|
1064
1064
|
});
|
|
1065
1065
|
});
|
|
1066
1066
|
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
// joinReducer — TOPIC numerics (332/333) on JOIN
|
|
1069
|
+
// ============================================================================
|
|
1070
|
+
//
|
|
1071
|
+
// RFC 2812 §3.2.1: after a successful JOIN the server sends 332 RPL_TOPIC
|
|
1072
|
+
// and 333 RPL_TOPICWHOTIME whenever a topic is set, regardless of how the
|
|
1073
|
+
// topic reached the authoritative ChannelState (KEEPTOPIC restore, a
|
|
1074
|
+
// deploy snapshot, or an earlier joiner). These tests pin the behaviour
|
|
1075
|
+
// that was previously missing for every non-KEEPTOPIC-restore code path.
|
|
1076
|
+
// ============================================================================
|
|
1077
|
+
|
|
1078
|
+
describe('joinReducer — TOPIC numerics on JOIN', () => {
|
|
1079
|
+
it('emits 332/333 to the joiner when state.topic is already set (deploy snapshot)', () => {
|
|
1080
|
+
const chan = makeChan('#foo');
|
|
1081
|
+
// Simulate a topic that survived a deploy in storage and was loaded
|
|
1082
|
+
// onto the authoritative ChannelState before this JOIN (empty roster,
|
|
1083
|
+
// no KEEPTOPIC restore path taken).
|
|
1084
|
+
chan.topic = { text: 'persisted topic', setter: 'alice!alice@example.com', setAt: 5_000 };
|
|
1085
|
+
const conn = makeConn('c1', 'bob');
|
|
1086
|
+
const ctx = makeCtx(conn);
|
|
1087
|
+
|
|
1088
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1089
|
+
|
|
1090
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1091
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :persisted topic');
|
|
1092
|
+
expect(
|
|
1093
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 5000')),
|
|
1094
|
+
).toBe(true);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('emits 332/333 to a second joiner when the topic was set by an earlier joiner', () => {
|
|
1098
|
+
const chan = makeChan('#foo');
|
|
1099
|
+
addMember(chan, 'c0', 'alice', true);
|
|
1100
|
+
// Topic set by an earlier joiner on a non-empty channel.
|
|
1101
|
+
chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 1_000 };
|
|
1102
|
+
const conn = makeConn('c1', 'bob');
|
|
1103
|
+
const ctx = makeCtx(conn);
|
|
1104
|
+
|
|
1105
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1106
|
+
|
|
1107
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1108
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :hello world');
|
|
1109
|
+
expect(
|
|
1110
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 1000')),
|
|
1111
|
+
).toBe(true);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('emits 332/333 after the 353/366 NAMES list (existing emission order)', () => {
|
|
1115
|
+
const chan = makeChan('#foo');
|
|
1116
|
+
chan.topic = { text: 't', setter: 's', setAt: 1 };
|
|
1117
|
+
const conn = makeConn('c1', 'bob');
|
|
1118
|
+
const ctx = makeCtx(conn);
|
|
1119
|
+
|
|
1120
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1121
|
+
|
|
1122
|
+
const send = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1123
|
+
const idx353 = send.findIndex((t) => t.includes(' 353 '));
|
|
1124
|
+
const idx366 = send.findIndex((t) => t.includes(' 366 '));
|
|
1125
|
+
const idx332 = send.findIndex((t) => t.includes(' 332 '));
|
|
1126
|
+
expect(idx353).toBeGreaterThanOrEqual(0);
|
|
1127
|
+
expect(idx366).toBeGreaterThan(idx353);
|
|
1128
|
+
expect(idx332).toBeGreaterThan(idx366);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('does not emit 332/333 when no topic is set', () => {
|
|
1132
|
+
const chan = makeChan('#foo');
|
|
1133
|
+
const conn = makeConn();
|
|
1134
|
+
const ctx = makeCtx(conn);
|
|
1135
|
+
|
|
1136
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1137
|
+
|
|
1138
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1139
|
+
expect(texts.some((t) => t.includes(' 332 '))).toBe(false);
|
|
1140
|
+
expect(texts.some((t) => t.includes(' 333 '))).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1067
1144
|
// ============================================================================
|
|
1068
1145
|
// joinReducer — ChanServ ACCESS auto-op / auto-voice
|
|
1069
1146
|
// ============================================================================
|
|
@@ -1269,6 +1346,108 @@ describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
|
|
|
1269
1346
|
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1270
1347
|
});
|
|
1271
1348
|
|
|
1349
|
+
it('does not auto-op the first joiner of an empty registered channel when off the access list', () => {
|
|
1350
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1351
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1352
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1353
|
+
services.registerChannel('#foo', 'alice');
|
|
1354
|
+
const chan = makeChan('#foo');
|
|
1355
|
+
expect(chan.members.size).toBe(0);
|
|
1356
|
+
const conn = makeConn('c1', 'bob');
|
|
1357
|
+
conn.account = 'bob';
|
|
1358
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1359
|
+
|
|
1360
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1361
|
+
|
|
1362
|
+
const entry = out.state.members.get('c1');
|
|
1363
|
+
expect(entry?.op).toBe(false);
|
|
1364
|
+
expect(entry?.voice).toBe(false);
|
|
1365
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1366
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1367
|
+
);
|
|
1368
|
+
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1369
|
+
expect(broadcasts.some((t) => t.includes('+o bob'))).toBe(false);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('auto-ops the first joiner of an empty registered channel when they hold AUTOOP-level access', () => {
|
|
1373
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1374
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1375
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1376
|
+
services.registerChannel('#foo', 'alice');
|
|
1377
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1378
|
+
const chan = makeChan('#foo');
|
|
1379
|
+
expect(chan.members.size).toBe(0);
|
|
1380
|
+
const conn = makeConn('c1', 'bob');
|
|
1381
|
+
conn.account = 'bob';
|
|
1382
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1383
|
+
|
|
1384
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1385
|
+
|
|
1386
|
+
const entry = out.state.members.get('c1');
|
|
1387
|
+
expect(entry?.op).toBe(true);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('does not auto-op an unidentified first joiner of an empty registered channel', () => {
|
|
1391
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1392
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1393
|
+
services.registerChannel('#foo', 'alice');
|
|
1394
|
+
const chan = makeChan('#foo');
|
|
1395
|
+
expect(chan.members.size).toBe(0);
|
|
1396
|
+
const conn = makeConn('c1', 'bob');
|
|
1397
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1398
|
+
|
|
1399
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1400
|
+
|
|
1401
|
+
const entry = out.state.members.get('c1');
|
|
1402
|
+
expect(entry?.op).toBe(false);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('still auto-ops the founder first-joining an empty registered channel', () => {
|
|
1406
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1407
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1408
|
+
services.registerChannel('#foo', 'alice');
|
|
1409
|
+
const chan = makeChan('#foo');
|
|
1410
|
+
expect(chan.members.size).toBe(0);
|
|
1411
|
+
const conn = makeConn('c1', 'alice');
|
|
1412
|
+
conn.account = 'alice';
|
|
1413
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1414
|
+
|
|
1415
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1416
|
+
|
|
1417
|
+
const entry = out.state.members.get('c1');
|
|
1418
|
+
expect(entry?.op).toBe(true);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('auto-ops the first joiner of an empty channel when services are bound but the channel is unregistered', () => {
|
|
1422
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1423
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1424
|
+
const chan = makeChan('#foo');
|
|
1425
|
+
expect(chan.members.size).toBe(0);
|
|
1426
|
+
const conn = makeConn('c1', 'bob');
|
|
1427
|
+
conn.account = 'bob';
|
|
1428
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1429
|
+
|
|
1430
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1431
|
+
|
|
1432
|
+
const entry = out.state.members.get('c1');
|
|
1433
|
+
expect(entry?.op).toBe(true);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('auto-ops the first joiner of an empty channel when no services store is bound', () => {
|
|
1437
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1438
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1439
|
+
const chan = makeChan('#foo');
|
|
1440
|
+
expect(chan.members.size).toBe(0);
|
|
1441
|
+
const conn = makeConn('c1', 'bob');
|
|
1442
|
+
conn.account = 'bob';
|
|
1443
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, undefined);
|
|
1444
|
+
|
|
1445
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1446
|
+
|
|
1447
|
+
const entry = out.state.members.get('c1');
|
|
1448
|
+
expect(entry?.op).toBe(true);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1272
1451
|
it('does nothing when no services store is bound', () => {
|
|
1273
1452
|
const chan = makeChan('#foo');
|
|
1274
1453
|
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
@@ -283,6 +283,144 @@ describe('nickservReducer — IDENTIFY', () => {
|
|
|
283
283
|
});
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
describe('nickservReducer — ID (alias for IDENTIFY)', () => {
|
|
287
|
+
it('sets +r and account on correct password (ID <password>)', () => {
|
|
288
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
289
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
290
|
+
const conn = makeConn();
|
|
291
|
+
const ctx = makeCtx(conn, services);
|
|
292
|
+
|
|
293
|
+
const out = nickservReducer(conn, privmsg('ID hunter2'), ctx);
|
|
294
|
+
|
|
295
|
+
expect(conn.account).toBe('alice');
|
|
296
|
+
expect(conn.userModes.registered).toBe(true);
|
|
297
|
+
expect(sentTexts(out.effects)).toContain(
|
|
298
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('is case-insensitive on the subcommand (id)', () => {
|
|
303
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
304
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
305
|
+
const conn = makeConn();
|
|
306
|
+
const ctx = makeCtx(conn, services);
|
|
307
|
+
|
|
308
|
+
nickservReducer(conn, privmsg('id hunter2'), ctx);
|
|
309
|
+
|
|
310
|
+
expect(conn.account).toBe('alice');
|
|
311
|
+
expect(conn.userModes.registered).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('rejects ID with wrong password (no +r, NOTICE only)', () => {
|
|
315
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
316
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
317
|
+
const conn = makeConn();
|
|
318
|
+
const ctx = makeCtx(conn, services);
|
|
319
|
+
|
|
320
|
+
const out = nickservReducer(conn, privmsg('ID wrongpw'), ctx);
|
|
321
|
+
|
|
322
|
+
expect(conn.account).toBeUndefined();
|
|
323
|
+
expect(conn.userModes.registered).toBe(false);
|
|
324
|
+
expect(sentTexts(out.effects)).toContain(
|
|
325
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
326
|
+
);
|
|
327
|
+
expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('sets +r and account for ID <nick> <password>', () => {
|
|
331
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
332
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
333
|
+
// Connection is using nick "bob" but identifies as "alice".
|
|
334
|
+
const conn = makeConn('c1', 'bob');
|
|
335
|
+
const ctx = makeCtx(conn, services);
|
|
336
|
+
|
|
337
|
+
const out = nickservReducer(conn, privmsg('ID alice hunter2'), ctx);
|
|
338
|
+
|
|
339
|
+
expect(conn.account).toBe('alice');
|
|
340
|
+
expect(conn.userModes.registered).toBe(true);
|
|
341
|
+
expect(sentTexts(out.effects)).toContain(
|
|
342
|
+
':NickServ!NickServ@services NOTICE bob :You are now identified for nick alice.',
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// IDENTIFY — HostServ vhost auto-apply on identify
|
|
349
|
+
// ============================================================================
|
|
350
|
+
|
|
351
|
+
describe('nickservReducer — IDENTIFY applies HostServ vhost', () => {
|
|
352
|
+
it('applies an assigned vhost on a successful IDENTIFY', () => {
|
|
353
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
354
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
355
|
+
services.setVhost('alice', 'cool.example.net');
|
|
356
|
+
const conn = makeConn();
|
|
357
|
+
conn.host = 'real.example.net';
|
|
358
|
+
const ctx = makeCtx(conn, services);
|
|
359
|
+
|
|
360
|
+
nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
361
|
+
|
|
362
|
+
expect(conn.account).toBe('alice');
|
|
363
|
+
expect(conn.vhostActive).toBe(true);
|
|
364
|
+
expect(conn.host).toBe('cool.example.net');
|
|
365
|
+
expect(conn.preVhostHost).toBe('real.example.net');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('leaves the host unchanged on IDENTIFY when no vhost is assigned', () => {
|
|
369
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
370
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
371
|
+
const conn = makeConn();
|
|
372
|
+
conn.host = 'real.example.net';
|
|
373
|
+
const ctx = makeCtx(conn, services);
|
|
374
|
+
|
|
375
|
+
nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
376
|
+
|
|
377
|
+
expect(conn.vhostActive).toBeUndefined();
|
|
378
|
+
expect(conn.host).toBe('real.example.net');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('emits a CHGHOST broadcast to joined peers on a post-JOIN IDENTIFY', () => {
|
|
382
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
383
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
384
|
+
services.setVhost('alice', 'cool.example.net');
|
|
385
|
+
const conn = makeConn();
|
|
386
|
+
conn.user = 'aliceuser';
|
|
387
|
+
conn.host = 'real.example.net';
|
|
388
|
+
conn.joinedChannels.add('#foo');
|
|
389
|
+
const ctx = makeCtx(conn, services);
|
|
390
|
+
|
|
391
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
392
|
+
|
|
393
|
+
const chghost = out.effects.filter((e) => e.tag === 'Broadcast' && e.cap === 'chghost');
|
|
394
|
+
expect(chghost).toHaveLength(1);
|
|
395
|
+
if (chghost[0]?.tag === 'Broadcast') {
|
|
396
|
+
expect(chghost[0].chan).toBe('#foo');
|
|
397
|
+
expect(chghost[0].capLines?.[0]?.text).toBe(
|
|
398
|
+
':alice!aliceuser@real.example.net CHGHOST aliceuser :cool.example.net',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('still emits the NickServ confirmation NOTICE and ACCOUNT broadcast alongside the vhost apply', () => {
|
|
404
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
405
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
406
|
+
services.setVhost('alice', 'cool.example.net');
|
|
407
|
+
const conn = makeConn();
|
|
408
|
+
conn.host = 'real.example.net';
|
|
409
|
+
conn.joinedChannels.add('#foo');
|
|
410
|
+
const ctx = makeCtx(conn, services);
|
|
411
|
+
|
|
412
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
413
|
+
|
|
414
|
+
expect(sentTexts(out.effects)).toContain(
|
|
415
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
416
|
+
);
|
|
417
|
+
const accountBcast = out.effects.find(
|
|
418
|
+
(e) => e.tag === 'Broadcast' && e.cap === 'account-notify',
|
|
419
|
+
);
|
|
420
|
+
expect(accountBcast).toBeDefined();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
286
424
|
describe('nickservReducer — DROP', () => {
|
|
287
425
|
it('removes the registration when the connection is identified', () => {
|
|
288
426
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
@@ -364,10 +502,11 @@ describe('nickservReducer — DROP', () => {
|
|
|
364
502
|
});
|
|
365
503
|
|
|
366
504
|
describe('nickservReducer — INFO', () => {
|
|
367
|
-
it('
|
|
505
|
+
it('shows the full record (incl. Email) to the owner via INFO with no target', () => {
|
|
368
506
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
369
507
|
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
370
508
|
const conn = makeConn();
|
|
509
|
+
conn.account = 'alice';
|
|
371
510
|
const ctx = makeCtx(conn, services);
|
|
372
511
|
|
|
373
512
|
const out = nickservReducer(conn, privmsg('INFO'), ctx);
|
|
@@ -378,10 +517,54 @@ describe('nickservReducer — INFO', () => {
|
|
|
378
517
|
expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Email: alice@example.com');
|
|
379
518
|
});
|
|
380
519
|
|
|
381
|
-
it('
|
|
520
|
+
it('withholds Email from a non-owner querying someone else nick', () => {
|
|
521
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
522
|
+
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
523
|
+
const conn = makeConn();
|
|
524
|
+
const ctx = makeCtx(conn, services);
|
|
525
|
+
|
|
526
|
+
const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
|
|
527
|
+
|
|
528
|
+
const texts = sentTexts(out.effects);
|
|
529
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: bob');
|
|
530
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Account: bob');
|
|
531
|
+
expect(texts).not.toContain(':NickServ!NickServ@services NOTICE alice :Email: bob@example.com');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('withholds Email from a non-owner querying their own nick when unidentified', () => {
|
|
535
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
536
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
537
|
+
const conn = makeConn();
|
|
538
|
+
const ctx = makeCtx(conn, services);
|
|
539
|
+
|
|
540
|
+
const out = nickservReducer(conn, privmsg('INFO'), ctx);
|
|
541
|
+
|
|
542
|
+
const texts = sentTexts(out.effects);
|
|
543
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: alice');
|
|
544
|
+
expect(texts).not.toContain(
|
|
545
|
+
':NickServ!NickServ@services NOTICE alice :Email: alice@example.com',
|
|
546
|
+
);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('shows Email to the owner querying their own nick by name', () => {
|
|
550
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
551
|
+
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
552
|
+
const conn = makeConn('c1', 'bob');
|
|
553
|
+
conn.account = 'bob';
|
|
554
|
+
const ctx = makeCtx(conn, services);
|
|
555
|
+
|
|
556
|
+
const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
|
|
557
|
+
|
|
558
|
+
const texts = sentTexts(out.effects);
|
|
559
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: bob');
|
|
560
|
+
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Email: bob@example.com');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('shows Email to an oper even when not the owner', () => {
|
|
382
564
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
383
565
|
services.registerNick('bob', 'pw', 'bob@example.com');
|
|
384
566
|
const conn = makeConn();
|
|
567
|
+
conn.userModes.oper = true;
|
|
385
568
|
const ctx = makeCtx(conn, services);
|
|
386
569
|
|
|
387
570
|
const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
|