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
|
@@ -908,12 +908,13 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
908
908
|
expect(lines[0]?.text).toContain('462');
|
|
909
909
|
});
|
|
910
910
|
|
|
911
|
-
it('routes PASS to passReducer (
|
|
911
|
+
it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
|
|
912
912
|
const { actor, runtime } = makeActor({});
|
|
913
913
|
await actor.receiveTextFrame('PASS sekrat\r\n');
|
|
914
|
-
// passReducer on
|
|
914
|
+
// passReducer on a registered conn with no AccountStore treats a bare
|
|
915
|
+
// server-password PASS as a silent no-op (no 462, no disconnect).
|
|
915
916
|
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
916
|
-
expect(sends).toHaveLength(
|
|
917
|
+
expect(sends).toHaveLength(0);
|
|
917
918
|
});
|
|
918
919
|
|
|
919
920
|
it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
|
|
@@ -4261,6 +4262,39 @@ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
|
|
|
4261
4262
|
const lines = sends[0]?.args[1] as RawLine[];
|
|
4262
4263
|
expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
|
|
4263
4264
|
});
|
|
4265
|
+
|
|
4266
|
+
it('silently ignores a nick-target MARKREAD (read markers are channel-scoped)', async () => {
|
|
4267
|
+
const store = new InMemoryMessageStore();
|
|
4268
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4269
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4270
|
+
runtime.calls.length = 0;
|
|
4271
|
+
|
|
4272
|
+
// A nick (non-channel) target falls through to the `return []` arm — the
|
|
4273
|
+
// deployment has no nick-scoped read markers, so the verb is a no-op
|
|
4274
|
+
// rather than a 421 or a FAIL.
|
|
4275
|
+
await actor.receiveTextFrame(`MARKREAD alice ${TS(2_000)}\r\n`);
|
|
4276
|
+
|
|
4277
|
+
expect(runtime.calls).toHaveLength(0);
|
|
4278
|
+
});
|
|
4279
|
+
|
|
4280
|
+
it('routes a parameterless MARKREAD through the reducer (NEED_MORE_PARAMS, never 421 fallthrough)', async () => {
|
|
4281
|
+
const store = new InMemoryMessageStore();
|
|
4282
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4283
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4284
|
+
runtime.calls.length = 0;
|
|
4285
|
+
|
|
4286
|
+
// No params at all → `msg.params[0] ?? ''` falls back to the empty
|
|
4287
|
+
// string, which is routed into the reducer so it can FAIL
|
|
4288
|
+
// NEED_MORE_PARAMS rather than hitting the generic 421 fallthrough.
|
|
4289
|
+
await actor.receiveTextFrame('MARKREAD\r\n');
|
|
4290
|
+
|
|
4291
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4292
|
+
expect(sends).toHaveLength(1);
|
|
4293
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4294
|
+
expect(lines[0]?.text).toContain('NEED_MORE_PARAMS');
|
|
4295
|
+
// Critical: never the generic 421 fallthrough for a cap-enabled client.
|
|
4296
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
4297
|
+
});
|
|
4264
4298
|
});
|
|
4265
4299
|
|
|
4266
4300
|
describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
|
|
@@ -4978,3 +5012,331 @@ describe('ConnectionActor — OperServ routing', () => {
|
|
|
4978
5012
|
);
|
|
4979
5013
|
});
|
|
4980
5014
|
});
|
|
5015
|
+
|
|
5016
|
+
// ============================================================================
|
|
5017
|
+
// echo-message — services PRIVMSG echo (IRCv3)
|
|
5018
|
+
//
|
|
5019
|
+
// When a client that negotiated `echo-message` sends `PRIVMSG <ServiceNick>`
|
|
5020
|
+
// the server must echo the sender's own PRIVMSG line back, BEFORE the service
|
|
5021
|
+
// reducer's NOTICE reply — matching how every other PRIVMSG renders (your
|
|
5022
|
+
// outgoing line first, then any reply). Without this echo,
|
|
5023
|
+
// modern IRCv3 clients optimistically show the outgoing message as "sending"
|
|
5024
|
+
// and flip it to "failed" when no delivery confirmation arrives.
|
|
5025
|
+
// ============================================================================
|
|
5026
|
+
|
|
5027
|
+
describe('ConnectionActor — echo-message on service PRIVMSG', () => {
|
|
5028
|
+
function makeOnlineConn(id: string, nick: string): ConnectionState {
|
|
5029
|
+
const c = createConnection({ id, connectedSince: 0 });
|
|
5030
|
+
c.nick = nick;
|
|
5031
|
+
c.user = nick;
|
|
5032
|
+
c.host = `${nick}.org`;
|
|
5033
|
+
c.realname = nick;
|
|
5034
|
+
c.registration = 'registered';
|
|
5035
|
+
return c;
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
it('echoes the sender PRIVMSG back when echo-message is negotiated (NickServ)', async () => {
|
|
5039
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5040
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5041
|
+
const { actor, runtime } = makeActor({
|
|
5042
|
+
services,
|
|
5043
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5044
|
+
});
|
|
5045
|
+
runtime.calls.length = 0;
|
|
5046
|
+
|
|
5047
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5048
|
+
|
|
5049
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5050
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5051
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5052
|
+
});
|
|
5053
|
+
|
|
5054
|
+
it('does NOT echo when echo-message is not negotiated', async () => {
|
|
5055
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5056
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5057
|
+
const { actor, runtime } = makeActor({ services });
|
|
5058
|
+
runtime.calls.length = 0;
|
|
5059
|
+
|
|
5060
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5061
|
+
|
|
5062
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5063
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5064
|
+
for (const t of allText) {
|
|
5065
|
+
expect(t).not.toMatch(/PRIVMSG NickServ :IDENTIFY hunter2$/);
|
|
5066
|
+
}
|
|
5067
|
+
});
|
|
5068
|
+
|
|
5069
|
+
it('emits the echo BEFORE the NickServ NOTICE reply (ordering)', async () => {
|
|
5070
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5071
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5072
|
+
const { actor, runtime } = makeActor({
|
|
5073
|
+
services,
|
|
5074
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5075
|
+
});
|
|
5076
|
+
runtime.calls.length = 0;
|
|
5077
|
+
|
|
5078
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5079
|
+
|
|
5080
|
+
const sendCalls = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5081
|
+
const findIdx = (needle: string): number =>
|
|
5082
|
+
sendCalls.findIndex((c) => (c.args[1] as RawLine[]).some((l) => l.text.includes(needle)));
|
|
5083
|
+
const noticeIdx = findIdx('NickServ!NickServ@services NOTICE');
|
|
5084
|
+
const echoIdx = findIdx('PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5085
|
+
expect(noticeIdx).toBeGreaterThanOrEqual(0);
|
|
5086
|
+
expect(echoIdx).toBeGreaterThanOrEqual(0);
|
|
5087
|
+
expect(echoIdx).toBeLessThan(noticeIdx);
|
|
5088
|
+
});
|
|
5089
|
+
|
|
5090
|
+
it('echoes the sender PRIVMSG back for ChanServ (shared fix, not NickServ-specific)', async () => {
|
|
5091
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5092
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5093
|
+
const { actor, runtime } = makeActor({
|
|
5094
|
+
services,
|
|
5095
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5096
|
+
});
|
|
5097
|
+
runtime.calls.length = 0;
|
|
5098
|
+
|
|
5099
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :HELP\r\n');
|
|
5100
|
+
|
|
5101
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5102
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5103
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG ChanServ :HELP');
|
|
5104
|
+
});
|
|
5105
|
+
|
|
5106
|
+
it('preserves the sender target casing in the echo', async () => {
|
|
5107
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5108
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5109
|
+
const { actor, runtime } = makeActor({
|
|
5110
|
+
services,
|
|
5111
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5112
|
+
});
|
|
5113
|
+
runtime.calls.length = 0;
|
|
5114
|
+
|
|
5115
|
+
await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
|
|
5116
|
+
|
|
5117
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5118
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5119
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG nickserv :IDENTIFY hunter2');
|
|
5120
|
+
});
|
|
5121
|
+
|
|
5122
|
+
it('does NOT intercept PRIVMSG to a non-service nick when services are bound', async () => {
|
|
5123
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5124
|
+
const { actor, runtime } = makeActor({ services });
|
|
5125
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
5126
|
+
runtime.calls.length = 0;
|
|
5127
|
+
|
|
5128
|
+
await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
|
|
5129
|
+
|
|
5130
|
+
// With services bound, a non-service nick still routes through the normal
|
|
5131
|
+
// user-target path (sendToNick), never the service reducer or echo path.
|
|
5132
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
5133
|
+
expect(toNick).toHaveLength(1);
|
|
5134
|
+
});
|
|
5135
|
+
|
|
5136
|
+
it('echoes with empty text when the PRIVMSG carries no trailing parameter', async () => {
|
|
5137
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5138
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5139
|
+
const { actor, runtime } = makeActor({
|
|
5140
|
+
services,
|
|
5141
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5142
|
+
});
|
|
5143
|
+
runtime.calls.length = 0;
|
|
5144
|
+
|
|
5145
|
+
// No `:text` argument → `singleParamMsg.params[1] ?? ''` falls back to an
|
|
5146
|
+
// empty string in the echo line.
|
|
5147
|
+
await actor.receiveTextFrame('PRIVMSG NickServ\r\n');
|
|
5148
|
+
|
|
5149
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5150
|
+
const echo = toAlice
|
|
5151
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5152
|
+
.find((t) => t.endsWith('PRIVMSG NickServ :'));
|
|
5153
|
+
expect(echo).toBe(':alice!alice@example.com PRIVMSG NickServ :');
|
|
5154
|
+
});
|
|
5155
|
+
|
|
5156
|
+
it('falls back to "?" hostmask in the echo when the sender has no nick', async () => {
|
|
5157
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5158
|
+
const { actor, runtime, conn } = makeActor({
|
|
5159
|
+
services,
|
|
5160
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5161
|
+
});
|
|
5162
|
+
// A connection that negotiated echo-message but has no NICK/USER/HOST —
|
|
5163
|
+
// exercises the `hostmaskOf(state) ?? state.nick ?? '?'` fallback chain
|
|
5164
|
+
// in the service-PRIVMSG echo. `delete` is required because
|
|
5165
|
+
// exactOptionalPropertyTypes forbids `= undefined`.
|
|
5166
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5167
|
+
delete conn.nick;
|
|
5168
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5169
|
+
delete conn.user;
|
|
5170
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5171
|
+
delete conn.host;
|
|
5172
|
+
runtime.calls.length = 0;
|
|
5173
|
+
|
|
5174
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5175
|
+
|
|
5176
|
+
const toSender = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5177
|
+
const echo = toSender
|
|
5178
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5179
|
+
.find((t) => t.includes('PRIVMSG NickServ :IDENTIFY hunter2'));
|
|
5180
|
+
// Both `??` arms fire: hostmaskOf returns undefined (no nick), then
|
|
5181
|
+
// state.nick is undefined, so the final '?' fallback is used.
|
|
5182
|
+
expect(echo).toBe(':? PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5183
|
+
});
|
|
5184
|
+
});
|
|
5185
|
+
|
|
5186
|
+
// ============================================================================
|
|
5187
|
+
// Service shortcut commands — NICKSERV/NS/CHANSERV/CS/HOSTSERV/HS/MEMOSERV/MS/
|
|
5188
|
+
// OPERSERV/OS (Atheme/Anope-convention aliases rewritten to
|
|
5189
|
+
// PRIVMSG <ServiceNick> :<joined args> by the dispatcher).
|
|
5190
|
+
// ============================================================================
|
|
5191
|
+
|
|
5192
|
+
describe('ConnectionActor — service shortcut commands', () => {
|
|
5193
|
+
it('NICKSERV IDENTIFY behaves identically to PRIVMSG NickServ :IDENTIFY (never 421)', async () => {
|
|
5194
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5195
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5196
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
5197
|
+
runtime.calls.length = 0;
|
|
5198
|
+
|
|
5199
|
+
await actor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5200
|
+
|
|
5201
|
+
expect(conn.account).toBe('alice');
|
|
5202
|
+
expect(conn.userModes.registered).toBe(true);
|
|
5203
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5204
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5205
|
+
expect(allText).toContain(
|
|
5206
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
5207
|
+
);
|
|
5208
|
+
// Critical: the shortcut must reach the reducer, never the 421 fallthrough.
|
|
5209
|
+
for (const t of allText) {
|
|
5210
|
+
expect(t).not.toContain(' 421 ');
|
|
5211
|
+
}
|
|
5212
|
+
});
|
|
5213
|
+
|
|
5214
|
+
// Each alias must resolve to its service. Sending the bare alias (no args)
|
|
5215
|
+
// reaches the reducer, which emits a help NOTICE sourced from the matching
|
|
5216
|
+
// pseudo-client — so the NOTICE source nick identifies the routed service.
|
|
5217
|
+
const aliasToService: ReadonlyArray<[string, string]> = [
|
|
5218
|
+
['NICKSERV', 'NickServ'],
|
|
5219
|
+
['NS', 'NickServ'],
|
|
5220
|
+
['CHANSERV', 'ChanServ'],
|
|
5221
|
+
['CS', 'ChanServ'],
|
|
5222
|
+
['HOSTSERV', 'HostServ'],
|
|
5223
|
+
['HS', 'HostServ'],
|
|
5224
|
+
['MEMOSERV', 'MemoServ'],
|
|
5225
|
+
['MS', 'MemoServ'],
|
|
5226
|
+
['OPERSERV', 'OperServ'],
|
|
5227
|
+
['OS', 'OperServ'],
|
|
5228
|
+
];
|
|
5229
|
+
|
|
5230
|
+
for (const [alias, service] of aliasToService) {
|
|
5231
|
+
it(`${alias} (no args) routes to ${service} via the help NOTICE`, async () => {
|
|
5232
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5233
|
+
const { actor, runtime } = makeActor({ services });
|
|
5234
|
+
runtime.calls.length = 0;
|
|
5235
|
+
|
|
5236
|
+
await actor.receiveTextFrame(`${alias}\r\n`);
|
|
5237
|
+
|
|
5238
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5239
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5240
|
+
// The help NOTICE source hostmask identifies which service was routed.
|
|
5241
|
+
expect(
|
|
5242
|
+
allText.some((t) => t.startsWith(`:${service}!${service}@services NOTICE alice :`)),
|
|
5243
|
+
).toBe(true);
|
|
5244
|
+
// And it never produces a 421.
|
|
5245
|
+
for (const t of allText) {
|
|
5246
|
+
expect(t).not.toContain(' 421 ');
|
|
5247
|
+
}
|
|
5248
|
+
});
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
it('aliases are case-insensitive (ns lower-case maps to NickServ)', async () => {
|
|
5252
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5253
|
+
const { actor, runtime } = makeActor({ services });
|
|
5254
|
+
runtime.calls.length = 0;
|
|
5255
|
+
|
|
5256
|
+
await actor.receiveTextFrame('ns\r\n');
|
|
5257
|
+
|
|
5258
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5259
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5260
|
+
expect(allText.some((t) => t.startsWith(':NickServ!NickServ@services NOTICE alice :'))).toBe(
|
|
5261
|
+
true,
|
|
5262
|
+
);
|
|
5263
|
+
});
|
|
5264
|
+
|
|
5265
|
+
it('joins multi-word args into the PRIVMSG trailing body (NICKSERV REGISTER pw email)', async () => {
|
|
5266
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5267
|
+
const { actor, runtime } = makeActor({ services });
|
|
5268
|
+
runtime.calls.length = 0;
|
|
5269
|
+
|
|
5270
|
+
// Three trailing tokens must be joined and delivered as the body, exactly
|
|
5271
|
+
// mirroring `PRIVMSG NickServ :REGISTER hunter2 alice@example.com`.
|
|
5272
|
+
await actor.receiveTextFrame('NICKSERV REGISTER hunter2 alice@example.com\r\n');
|
|
5273
|
+
|
|
5274
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
5275
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5276
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5277
|
+
expect(allText).toContain(
|
|
5278
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
|
|
5279
|
+
);
|
|
5280
|
+
});
|
|
5281
|
+
|
|
5282
|
+
it('joins a colon-trailing arg shape into the PRIVMSG body (NS :IDENTIFY hunter2)', async () => {
|
|
5283
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5284
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5285
|
+
const { actor, conn } = makeActor({ services });
|
|
5286
|
+
|
|
5287
|
+
// `NS :IDENTIFY hunter2` parses to params=['IDENTIFY hunter2']; joining
|
|
5288
|
+
// reproduces the body `IDENTIFY hunter2` so the identify succeeds.
|
|
5289
|
+
await actor.receiveTextFrame('NS :IDENTIFY hunter2\r\n');
|
|
5290
|
+
|
|
5291
|
+
expect(conn.account).toBe('alice');
|
|
5292
|
+
});
|
|
5293
|
+
|
|
5294
|
+
it('shortcut with no args is equivalent to PRIVMSG <ServiceNick> (help NOTICE)', async () => {
|
|
5295
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5296
|
+
const { actor: a1, runtime: r1 } = makeActor({ services });
|
|
5297
|
+
r1.calls.length = 0;
|
|
5298
|
+
await a1.receiveTextFrame('CS\r\n');
|
|
5299
|
+
const shortcutText = r1.calls
|
|
5300
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5301
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5302
|
+
|
|
5303
|
+
const { actor: a2, runtime: r2 } = makeActor({ services });
|
|
5304
|
+
r2.calls.length = 0;
|
|
5305
|
+
await a2.receiveTextFrame('PRIVMSG ChanServ :\r\n');
|
|
5306
|
+
const privmsgText = r2.calls
|
|
5307
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5308
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5309
|
+
|
|
5310
|
+
expect(shortcutText).toEqual(privmsgText);
|
|
5311
|
+
});
|
|
5312
|
+
|
|
5313
|
+
it('when services are unavailable, the shortcut matches a direct PRIVMSG to the service (401, never 421)', async () => {
|
|
5314
|
+
// No services store bound → the rewritten PRIVMSG NickServ falls through
|
|
5315
|
+
// to the normal user-target path (sendToNick + 401), exactly like a
|
|
5316
|
+
// direct PRIVMSG NickServ. The shortcut must NOT produce a bare 421.
|
|
5317
|
+
const { actor: shortcutActor, runtime: shortcutRuntime } = makeActor({});
|
|
5318
|
+
shortcutRuntime.calls.length = 0;
|
|
5319
|
+
await shortcutActor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5320
|
+
|
|
5321
|
+
const shortcutToNick = shortcutRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5322
|
+
expect(shortcutToNick).toHaveLength(1);
|
|
5323
|
+
const shortcutNotFound = shortcutToNick[0]?.args[3] as RawLine[] | undefined;
|
|
5324
|
+
expect(shortcutNotFound?.[0]?.text).toContain('401');
|
|
5325
|
+
|
|
5326
|
+
// Equivalence: a direct PRIVMSG NickServ with no store produces the same
|
|
5327
|
+
// 401 path, confirming the shortcut delegates identically.
|
|
5328
|
+
const { actor: directActor, runtime: directRuntime } = makeActor({});
|
|
5329
|
+
directRuntime.calls.length = 0;
|
|
5330
|
+
await directActor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5331
|
+
const directToNick = directRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5332
|
+
expect(directToNick).toHaveLength(1);
|
|
5333
|
+
|
|
5334
|
+
// Critical: neither path emits a bare 421 for the shortcut verb.
|
|
5335
|
+
const allShortcutText = shortcutRuntime.calls
|
|
5336
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5337
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5338
|
+
for (const t of allShortcutText) {
|
|
5339
|
+
expect(t).not.toContain(' 421 ');
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
});
|
|
@@ -263,12 +263,13 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
async feed(chunk: string): Promise<void> {
|
|
266
|
-
/* istanbul ignore
|
|
266
|
+
/* istanbul ignore start -- defensive: bindActor is called by the
|
|
267
267
|
harness before the client is returned, so the unbound-actor branch
|
|
268
268
|
is unreachable through the public API. */
|
|
269
269
|
if (this.actor === undefined) {
|
|
270
270
|
throw new Error(`client ${this.id} has no bound actor`);
|
|
271
271
|
}
|
|
272
|
+
/* istanbul ignore stop */
|
|
272
273
|
await this.actor.receive(chunk);
|
|
273
274
|
}
|
|
274
275
|
|
|
@@ -305,16 +306,18 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
305
306
|
this.closed = true;
|
|
306
307
|
// Best-effort QUIT so peers see the part broadcast; the runtime's
|
|
307
308
|
// disconnect handler is wired by the harness to unregister.
|
|
309
|
+
/* istanbul ignore start -- defensive: spawnClient binds the actor
|
|
310
|
+
before returning the client, so `this.actor` is never undefined
|
|
311
|
+
here. The guard exists purely as a teardown safety net. */
|
|
308
312
|
if (this.actor !== undefined) {
|
|
309
313
|
try {
|
|
310
314
|
await this.actor.receive('QUIT :client closed\r\n');
|
|
311
|
-
/* istanbul ignore next -- defensive: receive swallows
|
|
312
|
-
every error internally; the catch is purely a teardown safety
|
|
313
|
-
net for catastrophic runtime failures. */
|
|
314
315
|
} catch {
|
|
315
|
-
// no-op
|
|
316
|
+
// no-op -- receive swallows every error internally; this catch is
|
|
317
|
+
// purely a teardown safety net for catastrophic runtime failures.
|
|
316
318
|
}
|
|
317
319
|
}
|
|
320
|
+
/* istanbul ignore stop */
|
|
318
321
|
}
|
|
319
322
|
}
|
|
320
323
|
|
|
@@ -182,7 +182,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
182
182
|
await alice.waitForNumeric('366');
|
|
183
183
|
const namesLine = alice.received.find((l) => numericOf(l) === '353');
|
|
184
184
|
expect(namesLine).toBeDefined();
|
|
185
|
-
|
|
185
|
+
// namesLine is still `string | undefined` to TS (expect() does not
|
|
186
|
+
// narrow), so we route it back through expect() rather than a
|
|
187
|
+
// non-null assertion — toContain fails loudly if it were undefined.
|
|
188
|
+
expect(namesLine).toContain('@alice');
|
|
186
189
|
await harness.close();
|
|
187
190
|
});
|
|
188
191
|
|
|
@@ -246,7 +249,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
246
249
|
await alice.send('JOIN #room');
|
|
247
250
|
await bob.send('JOIN #room');
|
|
248
251
|
await untilAliceSeesBobJoin(alice, '#room');
|
|
249
|
-
alice.received.
|
|
252
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
253
|
+
// the registration + JOIN burst makes the no-self-echo assertion
|
|
254
|
+
// below non-vacuous: it scans real traffic and confirms the
|
|
255
|
+
// sender never receives her own PRIVMSG among any of it.
|
|
250
256
|
bob.received.length = 0;
|
|
251
257
|
await alice.send('PRIVMSG #room :hello team');
|
|
252
258
|
await bob.waitForLine((l) => l.includes('PRIVMSG #room :hello team'));
|
|
@@ -453,7 +459,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
453
459
|
await alice.send('JOIN #notice');
|
|
454
460
|
await bob.send('JOIN #notice');
|
|
455
461
|
await untilAliceSeesBobJoin(alice, '#notice');
|
|
456
|
-
alice.received.
|
|
462
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
463
|
+
// the registration + JOIN burst makes the NOTICE-no-error filter
|
|
464
|
+
// below non-vacuous: it scans real numeric traffic (353/366/…)
|
|
465
|
+
// and confirms none of it is a NOTICE error code.
|
|
457
466
|
bob.received.length = 0;
|
|
458
467
|
await alice.send('NOTICE #notice :quiet note');
|
|
459
468
|
await bob.waitForLine((l) => l.includes('NOTICE #notice :quiet note'));
|
|
@@ -469,13 +478,19 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
469
478
|
await harness.close();
|
|
470
479
|
});
|
|
471
480
|
|
|
472
|
-
it('scenario 27: PASS after registration is
|
|
481
|
+
it('scenario 27: bare PASS after registration is a silent no-op (no 462)', async () => {
|
|
473
482
|
harness = await factory.create();
|
|
474
483
|
const c = await harness.spawnClient({ nick: 'alice' });
|
|
475
484
|
await c.waitForNumeric('001');
|
|
476
485
|
c.received.length = 0;
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
// A bare server-password PASS is meaningless once registration has
|
|
487
|
+
// completed: it must be ignored (no 462, no disconnect). Send a
|
|
488
|
+
// PING in the same frame so ordering is guaranteed — by the time
|
|
489
|
+
// the PONG arrives, any 462 from the PASS would already be queued.
|
|
490
|
+
await c.send('PASS hunter2\r\nPING :post-pass');
|
|
491
|
+
await c.waitForLine((l) => l === 'PONG :post-pass');
|
|
492
|
+
const errAlreadyRegistered = c.received.filter((l) => numericOf(l) === '462');
|
|
493
|
+
expect(errAlreadyRegistered).toEqual([]);
|
|
479
494
|
await harness.close();
|
|
480
495
|
});
|
|
481
496
|
|
|
@@ -191,6 +191,25 @@ describe('InMemoryClientHarness.waitForNumeric', () => {
|
|
|
191
191
|
expect(result).toContain('004');
|
|
192
192
|
await h.close();
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
it('ignores received lines that carry no :server prefix when scanning', async () => {
|
|
196
|
+
// A PONG reply is emitted without a `:server` prefix (`PONG :tok`, not
|
|
197
|
+
// `:srv PONG :tok`). waitForNumeric must scan such a line without
|
|
198
|
+
// false-matching: numericEquals takes its no-prefix branch (start=0),
|
|
199
|
+
// rejects the line, and keeps scanning until the real numeric lands.
|
|
200
|
+
const h = new InMemoryHarness();
|
|
201
|
+
const c = await h.spawnClient({ nick: 'alice' });
|
|
202
|
+
await c.waitForNumeric('001');
|
|
203
|
+
// Isolate a bare (no-prefix) line first, then drive a prefixed numeric
|
|
204
|
+
// after it, so the scan MUST walk past the PONG to reach 375.
|
|
205
|
+
c.received.length = 0;
|
|
206
|
+
await c.send('PING :bare');
|
|
207
|
+
await c.waitForLine((l) => l === 'PONG :bare');
|
|
208
|
+
await c.send('MOTD');
|
|
209
|
+
const result = await c.waitForNumeric('375');
|
|
210
|
+
expect(result).toContain('375');
|
|
211
|
+
await h.close();
|
|
212
|
+
});
|
|
194
213
|
});
|
|
195
214
|
|
|
196
215
|
describe('InMemoryClientHarness.close', () => {
|
|
@@ -7,7 +7,12 @@ export default defineConfig({
|
|
|
7
7
|
provider: 'v8',
|
|
8
8
|
all: false,
|
|
9
9
|
include: ['src/**/*.ts'],
|
|
10
|
-
|
|
10
|
+
// harness.ts exports types/interfaces only — it compiles to a bare
|
|
11
|
+
// `export {}` with zero runtime statements, so v8 reports it as a
|
|
12
|
+
// 0/0 (0%-statements) file that drags the file list down. Excluding
|
|
13
|
+
// it keeps the report honest: only modules with runtime code are
|
|
14
|
+
// scored. index.ts is excluded as the pure re-export barrel.
|
|
15
|
+
exclude: ['src/**/index.ts', 'src/harness.ts'],
|
|
11
16
|
reporter: ['text', 'html', 'json-summary'],
|
|
12
17
|
thresholds: {
|
|
13
18
|
lines: 90,
|
|
@@ -134,10 +134,10 @@ export class IrcLoadClient {
|
|
|
134
134
|
const t0 = Date.now();
|
|
135
135
|
this.ws = new this.WebSocketCtor(this.config.target, [...WS_SUBPROTOCOLS]);
|
|
136
136
|
const timer = setTimeout(() => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
137
|
+
// The timer is always cleared by the open/error handlers before
|
|
138
|
+
// teardown could run, so `settled` is necessarily false here.
|
|
139
|
+
this.failWaiters(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
|
|
140
|
+
reject(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
|
|
141
141
|
}, this.config.connectTimeoutMs);
|
|
142
142
|
|
|
143
143
|
this.ws.once('open', () => {
|
|
@@ -245,13 +245,11 @@ export class IrcLoadClient {
|
|
|
245
245
|
if (line.length === 0) continue;
|
|
246
246
|
// Dispatch to the first matching waiter (FIFO preserves phase order).
|
|
247
247
|
const idx = this.waiters.findIndex((w) => w.predicate(line));
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
waiter.resolve(line);
|
|
254
|
-
}
|
|
248
|
+
const waiter = idx >= 0 ? this.waiters[idx] : undefined;
|
|
249
|
+
if (waiter !== undefined) {
|
|
250
|
+
this.waiters.splice(idx, 1);
|
|
251
|
+
clearTimeout(waiter.timer);
|
|
252
|
+
waiter.resolve(line);
|
|
255
253
|
}
|
|
256
254
|
}
|
|
257
255
|
}
|
|
@@ -282,8 +280,10 @@ export class IrcLoadClient {
|
|
|
282
280
|
): Promise<string> {
|
|
283
281
|
return new Promise<string>((resolve, reject) => {
|
|
284
282
|
const timer = setTimeout(() => {
|
|
283
|
+
// The timer is always cleared when the waiter is removed
|
|
284
|
+
// (onMessage/failWaiters), so the waiter is still present here.
|
|
285
285
|
const idx = this.waiters.findIndex((w) => w.resolve === resolve);
|
|
286
|
-
|
|
286
|
+
this.waiters.splice(idx, 1);
|
|
287
287
|
reject(new Error(`timed out waiting for ${label} (${timeoutMs}ms)`));
|
|
288
288
|
}, timeoutMs);
|
|
289
289
|
this.waiters.push({ predicate, resolve, reject, phase, label, timer });
|
|
@@ -301,7 +301,7 @@ export class IrcLoadClient {
|
|
|
301
301
|
|
|
302
302
|
/** Idempotently closes the socket and drains waiters. */
|
|
303
303
|
private teardown(): void {
|
|
304
|
-
|
|
304
|
+
// teardown is only ever called once from run()'s finally.
|
|
305
305
|
this.settled = true;
|
|
306
306
|
this.failWaiters(new Error('client teardown'));
|
|
307
307
|
try {
|