serverless-ircd 0.4.0 → 0.5.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { killReducer } from '../../src/commands/kill';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const serverConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {
22
+ return buildCtx({
23
+ serverConfig,
24
+ clock,
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: conn,
28
+ });
29
+ }
30
+
31
+ function makeOper(id = 'c1', nick = 'alice'): ConnectionState {
32
+ const s = createConnection({ id, connectedSince: 0 });
33
+ s.nick = nick;
34
+ s.user = nick;
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ s.userModes.oper = true;
39
+ return s;
40
+ }
41
+
42
+ function makeTarget(id = 'c2', nick = 'bob'): ConnectionState {
43
+ const s = createConnection({ id, connectedSince: 0 });
44
+ s.nick = nick;
45
+ s.user = nick;
46
+ s.host = 'example.com';
47
+ s.realname = 'Bob';
48
+ s.registration = 'registered';
49
+ return s;
50
+ }
51
+
52
+ const L = (text: string): RawLine => ({ text });
53
+
54
+ const kill = (nick?: string, comment?: string) =>
55
+ ({
56
+ command: 'KILL',
57
+ params: nick === undefined ? [] : comment === undefined ? [nick] : [nick, comment],
58
+ tags: {},
59
+ }) as const;
60
+
61
+ describe('killReducer — oper happy path', () => {
62
+ it('emits a Disconnect effect for the target with a kill-formatted reason', () => {
63
+ const oper = makeOper();
64
+ const target = makeTarget();
65
+ const ctx = makeCtx(oper);
66
+
67
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
68
+
69
+ expect(out.effects).toEqual<EffectType[]>([
70
+ Effect.disconnect('c2', 'Killed (alice (flooding))'),
71
+ ]);
72
+ });
73
+
74
+ it('uses the oper display nick in the reason, not the target nick', () => {
75
+ const oper = makeOper('c1', 'theOper');
76
+ const target = makeTarget('c2', 'bob');
77
+ const ctx = makeCtx(oper);
78
+
79
+ const out = killReducer(target, kill('bob', 'bye'), ctx);
80
+
81
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (theOper (bye))')]);
82
+ });
83
+
84
+ it('formats multi-word comments verbatim inside the inner parens', () => {
85
+ const oper = makeOper();
86
+ const target = makeTarget();
87
+ const ctx = makeCtx(oper);
88
+
89
+ const out = killReducer(target, kill('bob', 'stop flooding the channel'), ctx);
90
+
91
+ expect(out.effects).toEqual<EffectType[]>([
92
+ Effect.disconnect('c2', 'Killed (alice (stop flooding the channel))'),
93
+ ]);
94
+ });
95
+
96
+ it('updates the oper lastSeen to ctx.clock.now()', () => {
97
+ const oper = makeOper();
98
+ expect(oper.lastSeen).toBe(0);
99
+ const ctx = makeCtx(oper);
100
+
101
+ killReducer(makeTarget(), kill('bob', 'flooding'), ctx);
102
+
103
+ expect(oper.lastSeen).toBe(5_000);
104
+ });
105
+
106
+ it('falls back to "*" for the oper nick in the reason when the oper has none', () => {
107
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
108
+ oper.userModes.oper = true;
109
+ const target = makeTarget();
110
+ const ctx = makeCtx(oper);
111
+
112
+ const out = killReducer(target, kill('bob', 'x'), ctx);
113
+
114
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (* (x))')]);
115
+ });
116
+ });
117
+
118
+ describe('killReducer — self-kill (RFC-permitted)', () => {
119
+ it('allows an oper to KILL themselves, disconnecting their own connection', () => {
120
+ const oper = makeOper('c1', 'alice');
121
+ const ctx = makeCtx(oper);
122
+
123
+ const out = killReducer(oper, kill('alice', 'self-terminate'), ctx);
124
+
125
+ expect(out.effects).toEqual<EffectType[]>([
126
+ Effect.disconnect('c1', 'Killed (alice (self-terminate))'),
127
+ ]);
128
+ });
129
+ });
130
+
131
+ describe('killReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
132
+ it('emits 481 when the sender is not an oper', () => {
133
+ const oper = makeOper();
134
+ oper.userModes.oper = false;
135
+ const target = makeTarget();
136
+ const ctx = makeCtx(oper);
137
+
138
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
139
+
140
+ expect(out.effects).toEqual<EffectType[]>([
141
+ Effect.send('c1', [
142
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
143
+ ]),
144
+ ]);
145
+ });
146
+
147
+ it('does not disconnect the target when the sender is not an oper', () => {
148
+ const oper = makeOper();
149
+ oper.userModes.oper = false;
150
+ const target = makeTarget();
151
+ const ctx = makeCtx(oper);
152
+
153
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
154
+
155
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
156
+ });
157
+
158
+ it('gates before param validation: a non-oper with missing params still gets 481', () => {
159
+ const oper = makeOper();
160
+ oper.userModes.oper = false;
161
+ const ctx = makeCtx(oper);
162
+
163
+ const out = killReducer(undefined, kill(), ctx);
164
+
165
+ expect(out.effects).toEqual<EffectType[]>([
166
+ Effect.send('c1', [
167
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
168
+ ]),
169
+ ]);
170
+ });
171
+
172
+ it('uses "*" as the nick in 481 when the connection has no nick', () => {
173
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
174
+ oper.userModes.oper = false;
175
+ const ctx = makeCtx(oper);
176
+
177
+ const out = killReducer(undefined, kill('bob', 'x'), ctx);
178
+
179
+ expect(out.effects).toEqual<EffectType[]>([
180
+ Effect.send('c1', [
181
+ L(":irc.example.com 481 * :Permission Denied - You're not an IRC operator"),
182
+ ]),
183
+ ]);
184
+ });
185
+ });
186
+
187
+ describe('killReducer — missing parameters (461 ERR_NEEDMOREPARAMS)', () => {
188
+ it('emits 461 when no parameters are supplied', () => {
189
+ const oper = makeOper();
190
+ const ctx = makeCtx(oper);
191
+
192
+ const out = killReducer(undefined, kill(), ctx);
193
+
194
+ expect(out.effects).toEqual<EffectType[]>([
195
+ Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
196
+ ]);
197
+ });
198
+
199
+ it('emits 461 when only the nick is supplied (no comment)', () => {
200
+ const oper = makeOper();
201
+ const ctx = makeCtx(oper);
202
+
203
+ const out = killReducer(makeTarget(), kill('bob'), ctx);
204
+
205
+ expect(out.effects).toEqual<EffectType[]>([
206
+ Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
207
+ ]);
208
+ });
209
+
210
+ it('uses "*" as the nick in 461 when the connection has no nick', () => {
211
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
212
+ oper.userModes.oper = true;
213
+ const ctx = makeCtx(oper);
214
+
215
+ const out = killReducer(undefined, kill('bob'), ctx);
216
+
217
+ expect(out.effects).toEqual<EffectType[]>([
218
+ Effect.send('c1', [L(':irc.example.com 461 * KILL :Not enough parameters')]),
219
+ ]);
220
+ });
221
+ });
222
+
223
+ describe('killReducer — unknown nick (401 ERR_NOSUCHNICK)', () => {
224
+ it('emits 401 when the target resolves to undefined', () => {
225
+ const oper = makeOper();
226
+ const ctx = makeCtx(oper);
227
+
228
+ const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
229
+
230
+ expect(out.effects).toEqual<EffectType[]>([
231
+ Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),
232
+ ]);
233
+ });
234
+
235
+ it('does not emit a Disconnect when the target is unknown', () => {
236
+ const oper = makeOper();
237
+ const ctx = makeCtx(oper);
238
+
239
+ const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
240
+
241
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
242
+ });
243
+ });
@@ -0,0 +1,368 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { lusersReducer } from '../../src/commands/lusers';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ SequentialIdFactory,
9
+ type ServerStatsSnapshot,
10
+ } from '../../src/ports';
11
+ import { createConnection } from '../../src/state/connection';
12
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
13
+
14
+ const serverConfig: ServerConfig = {
15
+ serverName: 'irc.example.com',
16
+ networkName: 'ExampleNet',
17
+ maxChannelsPerUser: 30,
18
+ maxTargetsPerCommand: 10,
19
+ maxListEntries: 50,
20
+ nickLen: 30,
21
+ channelLen: 50,
22
+ topicLen: 390,
23
+ quitMessage: 'Client Quit',
24
+ };
25
+
26
+ function makeCtx(connId = 'c1', nick = 'alice', clock = new FakeClock(5_000)): Ctx {
27
+ const conn = createConnection({ id: connId, connectedSince: 0 });
28
+ conn.nick = nick;
29
+ conn.user = nick;
30
+ conn.host = 'example.com';
31
+ conn.realname = 'Alice';
32
+ conn.registration = 'registered';
33
+ return buildCtx({
34
+ serverConfig,
35
+ clock,
36
+ ids: new SequentialIdFactory(),
37
+ motd: EmptyMotdProvider,
38
+ connection: conn,
39
+ });
40
+ }
41
+
42
+ const L = (text: string): RawLine => ({ text });
43
+
44
+ const lusers = (...params: string[]) =>
45
+ ({
46
+ command: 'LUSERS',
47
+ params,
48
+ tags: {},
49
+ }) as const;
50
+
51
+ const snap = (overrides: Partial<ServerStatsSnapshot> = {}): ServerStatsSnapshot => ({
52
+ users: 2,
53
+ invisible: 1,
54
+ opers: 0,
55
+ unknownConnections: 0,
56
+ channels: 0,
57
+ servers: 1,
58
+ localConns: 3,
59
+ globalConns: 3,
60
+ maxLocalConns: 5,
61
+ maxGlobalConns: 5,
62
+ uptimeStartedAt: 1_000,
63
+ ...overrides,
64
+ });
65
+
66
+ // ============================================================================
67
+ // lusersReducer — 251–255 baseline
68
+ // ============================================================================
69
+
70
+ describe('lusersReducer — 251–255 baseline', () => {
71
+ it('emits 251 RPL_LUSERCLIENT with the visible/invisible/server counts', () => {
72
+ const ctx = makeCtx();
73
+
74
+ const out = lusersReducer(snap(), lusers(), ctx);
75
+
76
+ expect(out.effects).toContainEqual<EffectType>(
77
+ Effect.send('c1', [
78
+ L(':irc.example.com 251 alice :There are 2 users and 1 invisible on 1 servers'),
79
+ ]),
80
+ );
81
+ });
82
+
83
+ it('emits 255 RPL_LUSERME with the local client/server counts', () => {
84
+ const ctx = makeCtx();
85
+
86
+ const out = lusersReducer(snap(), lusers(), ctx);
87
+
88
+ expect(out.effects).toContainEqual<EffectType>(
89
+ Effect.send('c1', [L(':irc.example.com 255 alice :I have 3 clients and 1 servers')]),
90
+ );
91
+ });
92
+
93
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
94
+ const ctx = makeCtx();
95
+ expect(ctx.connection.lastSeen).toBe(0);
96
+
97
+ lusersReducer(snap(), lusers(), ctx);
98
+
99
+ expect(ctx.connection.lastSeen).toBe(5_000);
100
+ });
101
+
102
+ it('uses "*" as the nick when the requester has none', () => {
103
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
104
+ conn.registration = 'registered';
105
+ const ctx = buildCtx({
106
+ serverConfig,
107
+ clock: new FakeClock(5_000),
108
+ ids: new SequentialIdFactory(),
109
+ motd: EmptyMotdProvider,
110
+ connection: conn,
111
+ });
112
+
113
+ const out = lusersReducer(snap(), lusers(), ctx);
114
+
115
+ expect(out.effects).toContainEqual<EffectType>(
116
+ Effect.send('c1', [
117
+ L(':irc.example.com 251 * :There are 2 users and 1 invisible on 1 servers'),
118
+ ]),
119
+ );
120
+ });
121
+ });
122
+
123
+ // ============================================================================
124
+ // lusersReducer — non-zero omission (RFC: only emit 252/253/254 when non-zero)
125
+ // ============================================================================
126
+
127
+ describe('lusersReducer — non-zero omission', () => {
128
+ it('emits 252 RPL_LUSEROP when opers is non-zero', () => {
129
+ const ctx = makeCtx();
130
+
131
+ const out = lusersReducer(snap({ opers: 2 }), lusers(), ctx);
132
+
133
+ expect(out.effects).toContainEqual<EffectType>(
134
+ Effect.send('c1', [L(':irc.example.com 252 alice 2 :operator(s) online')]),
135
+ );
136
+ });
137
+
138
+ it('omits 252 when opers is zero', () => {
139
+ const ctx = makeCtx();
140
+
141
+ const out = lusersReducer(snap({ opers: 0 }), lusers(), ctx);
142
+
143
+ const text = out.effects
144
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
145
+ .join('|');
146
+ expect(text).not.toContain(' 252 ');
147
+ });
148
+
149
+ it('emits 253 RPL_LUSERUNKNOWN when unknownConnections is non-zero', () => {
150
+ const ctx = makeCtx();
151
+
152
+ const out = lusersReducer(snap({ unknownConnections: 4 }), lusers(), ctx);
153
+
154
+ expect(out.effects).toContainEqual<EffectType>(
155
+ Effect.send('c1', [L(':irc.example.com 253 alice 4 :unknown connection(s)')]),
156
+ );
157
+ });
158
+
159
+ it('omits 253 when unknownConnections is zero', () => {
160
+ const ctx = makeCtx();
161
+
162
+ const out = lusersReducer(snap({ unknownConnections: 0 }), lusers(), ctx);
163
+
164
+ const text = out.effects
165
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
166
+ .join('|');
167
+ expect(text).not.toContain(' 253 ');
168
+ });
169
+
170
+ it('emits 254 RPL_LUSERCHANNELS when channels is non-zero', () => {
171
+ const ctx = makeCtx();
172
+
173
+ const out = lusersReducer(snap({ channels: 7 }), lusers(), ctx);
174
+
175
+ expect(out.effects).toContainEqual<EffectType>(
176
+ Effect.send('c1', [L(':irc.example.com 254 alice 7 :channel(s) formed')]),
177
+ );
178
+ });
179
+
180
+ it('omits 254 when channels is zero', () => {
181
+ const ctx = makeCtx();
182
+
183
+ const out = lusersReducer(snap({ channels: 0 }), lusers(), ctx);
184
+
185
+ const text = out.effects
186
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
187
+ .join('|');
188
+ expect(text).not.toContain(' 254 ');
189
+ });
190
+ });
191
+
192
+ // ============================================================================
193
+ // lusersReducer — 265/266 local/global context
194
+ // ============================================================================
195
+
196
+ describe('lusersReducer — 265/266 local/global', () => {
197
+ it('emits 265 RPL_LOCALUSERS with the current and max local counts', () => {
198
+ const ctx = makeCtx();
199
+
200
+ const out = lusersReducer(snap({ localConns: 3, maxLocalConns: 5 }), lusers(), ctx);
201
+
202
+ expect(out.effects).toContainEqual<EffectType>(
203
+ Effect.send('c1', [L(':irc.example.com 265 alice 3 5 :Current local users 3, max 5')]),
204
+ );
205
+ });
206
+
207
+ it('emits 266 RPL_GLOBALUSERS with the current and max global counts', () => {
208
+ const ctx = makeCtx();
209
+
210
+ const out = lusersReducer(snap({ globalConns: 3, maxGlobalConns: 5 }), lusers(), ctx);
211
+
212
+ expect(out.effects).toContainEqual<EffectType>(
213
+ Effect.send('c1', [L(':irc.example.com 266 alice 3 5 :Current global users 3, max 5')]),
214
+ );
215
+ });
216
+
217
+ it('always emits 265 and 266 (modern clients rely on them for the user list count)', () => {
218
+ const ctx = makeCtx();
219
+
220
+ const out = lusersReducer(snap(), lusers(), ctx);
221
+
222
+ const text = out.effects
223
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
224
+ .join('|');
225
+ expect(text).toContain(' 265 ');
226
+ expect(text).toContain(' 266 ');
227
+ });
228
+ });
229
+
230
+ // ============================================================================
231
+ // lusersReducer — empty deployment
232
+ // ============================================================================
233
+
234
+ describe('lusersReducer — empty deployment', () => {
235
+ it('still emits 251 and 255 reporting zero counts', () => {
236
+ const ctx = makeCtx();
237
+ const empty: ServerStatsSnapshot = {
238
+ users: 0,
239
+ invisible: 0,
240
+ opers: 0,
241
+ unknownConnections: 0,
242
+ channels: 0,
243
+ servers: 1,
244
+ localConns: 0,
245
+ globalConns: 0,
246
+ maxLocalConns: 0,
247
+ maxGlobalConns: 0,
248
+ uptimeStartedAt: 1_000,
249
+ };
250
+
251
+ const out = lusersReducer(empty, lusers(), ctx);
252
+
253
+ expect(out.effects).toContainEqual<EffectType>(
254
+ Effect.send('c1', [
255
+ L(':irc.example.com 251 alice :There are 0 users and 0 invisible on 1 servers'),
256
+ ]),
257
+ );
258
+ expect(out.effects).toContainEqual<EffectType>(
259
+ Effect.send('c1', [L(':irc.example.com 255 alice :I have 0 clients and 1 servers')]),
260
+ );
261
+ });
262
+ });
263
+
264
+ // ============================================================================
265
+ // lusersReducer — remote server target
266
+ // ============================================================================
267
+
268
+ describe('lusersReducer — remote server target', () => {
269
+ it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
270
+ const ctx = makeCtx();
271
+
272
+ const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');
273
+
274
+ expect(out.effects).toEqual<EffectType[]>([
275
+ Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
276
+ ]);
277
+ });
278
+
279
+ it('uses "*" as the nick in 402 when the requester has none', () => {
280
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
281
+ conn.registration = 'registered';
282
+ const ctx = buildCtx({
283
+ serverConfig,
284
+ clock: new FakeClock(5_000),
285
+ ids: new SequentialIdFactory(),
286
+ motd: EmptyMotdProvider,
287
+ connection: conn,
288
+ });
289
+
290
+ const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');
291
+
292
+ expect(out.effects).toEqual<EffectType[]>([
293
+ Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
294
+ ]);
295
+ });
296
+
297
+ it('does not emit 402 when no serverTarget is flagged', () => {
298
+ const ctx = makeCtx();
299
+
300
+ const out = lusersReducer(snap(), lusers(), ctx);
301
+
302
+ const text = out.effects
303
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
304
+ .join('|');
305
+ expect(text).not.toContain(' 402 ');
306
+ });
307
+ });
308
+
309
+ // ============================================================================
310
+ // lusersReducer — ordering
311
+ // ============================================================================
312
+
313
+ describe('lusersReducer — ordering', () => {
314
+ it('emits numerics in ascending code order (251, 252, 253, 254, 255, 265, 266)', () => {
315
+ const ctx = makeCtx();
316
+ const populated = snap({
317
+ opers: 1,
318
+ unknownConnections: 1,
319
+ channels: 1,
320
+ });
321
+
322
+ const out = lusersReducer(populated, lusers(), ctx);
323
+
324
+ const codes: number[] = [];
325
+ for (const e of out.effects) {
326
+ if (e.tag !== 'Send') continue;
327
+ for (const line of e.lines) {
328
+ const m = line.text.match(/\s(\d{3})\s/);
329
+ if (m) codes.push(Number(m[1]));
330
+ }
331
+ }
332
+ expect(codes).toEqual([251, 252, 253, 254, 255, 265, 266]);
333
+ });
334
+ });
335
+
336
+ // ============================================================================
337
+ // lusersReducer — "*" nick fallback for count/usage lines
338
+ // ============================================================================
339
+
340
+ describe('lusersReducer — "*" nick fallback for count/usage lines', () => {
341
+ it('uses "*" in 252/253/254/265/266 when the requester has no nick', () => {
342
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
343
+ conn.registration = 'registered';
344
+ const ctx = buildCtx({
345
+ serverConfig,
346
+ clock: new FakeClock(5_000),
347
+ ids: new SequentialIdFactory(),
348
+ motd: EmptyMotdProvider,
349
+ connection: conn,
350
+ });
351
+
352
+ const out = lusersReducer(
353
+ snap({ opers: 1, unknownConnections: 1, channels: 1 }),
354
+ lusers(),
355
+ ctx,
356
+ );
357
+
358
+ const text = out.effects
359
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
360
+ .join('|');
361
+ // Every emitted numeric uses "*" as the requester nick.
362
+ expect(text).toContain(' 252 * 1 ');
363
+ expect(text).toContain(' 253 * 1 ');
364
+ expect(text).toContain(' 254 * 1 ');
365
+ expect(text).toContain(' 265 * 3 5 ');
366
+ expect(text).toContain(' 266 * 3 5 ');
367
+ });
368
+ });
@@ -200,6 +200,36 @@ describe('channelModeReducer — prefix modes', () => {
200
200
  ]);
201
201
  });
202
202
 
203
+ it('+oo <nick1> <nick2> grants op to both members in one combined delta', () => {
204
+ // Exercises the already-initialized branch of `pushMembershipChange`:
205
+ // the second membership push reuses the `delta.memberships` array the
206
+ // first one created.
207
+ const chan = makeChan('#foo');
208
+ addMember(chan, 'c1', 'alice', true);
209
+ addMember(chan, 'c2', 'bob');
210
+ addMember(chan, 'c3', 'carol');
211
+ const conn = makeConn();
212
+ const ctx = makeCtx(conn);
213
+
214
+ const out = channelModeReducer(
215
+ chan,
216
+ { command: 'MODE', params: ['#foo', '+oo', 'bob', 'carol'], tags: {} },
217
+ ctx,
218
+ );
219
+
220
+ expect(chan.members.get('c2')?.op).toBe(true);
221
+ expect(chan.members.get('c3')?.op).toBe(true);
222
+ expect(out.effects).toEqual<EffectType[]>([
223
+ Effect.applyChannelDelta('#foo', {
224
+ memberships: [
225
+ { type: 'add', conn: 'c2', nick: 'bob', op: true, voice: false },
226
+ { type: 'add', conn: 'c3', nick: 'carol', op: true, voice: false },
227
+ ],
228
+ }),
229
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +oo bob carol')]),
230
+ ]);
231
+ });
232
+
203
233
  it('-o <nick> removes op from the named member', () => {
204
234
  const chan = makeChan('#foo');
205
235
  addMember(chan, 'c1', 'alice', true);
@@ -483,6 +513,33 @@ describe('channelModeReducer — ban masks', () => {
483
513
  ]);
484
514
  });
485
515
 
516
+ it('+bb <mask1> <mask2> adds both masks in one combined delta', () => {
517
+ // Exercises the already-initialized branch of `pushBanMaskChange`: the
518
+ // second ban push reuses the `delta.banMaskChanges` array the first one
519
+ // created.
520
+ const chan = makeChan('#foo');
521
+ addMember(chan, 'c1', 'alice', true);
522
+ const conn = makeConn();
523
+ const ctx = makeCtx(conn);
524
+
525
+ const out = channelModeReducer(
526
+ chan,
527
+ { command: 'MODE', params: ['#foo', '+bb', 'bob!*@*', 'carol!*@*'], tags: {} },
528
+ ctx,
529
+ );
530
+
531
+ expect([...chan.banMasks]).toEqual(['bob!*@*', 'carol!*@*']);
532
+ expect(out.effects).toEqual<EffectType[]>([
533
+ Effect.applyChannelDelta('#foo', {
534
+ banMaskChanges: [
535
+ { type: 'add', mask: 'bob!*@*' },
536
+ { type: 'add', mask: 'carol!*@*' },
537
+ ],
538
+ }),
539
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +bb bob!*@* carol!*@*')]),
540
+ ]);
541
+ });
542
+
486
543
  it('-b <mask> removes from the ban list', () => {
487
544
  const chan = makeChan('#foo');
488
545
  chan.banMasks.add('bob!*@*');