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.
- package/CHANGELOG.md +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { traceReducer } from '../../src/commands/trace';
|
|
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 makeConn(id = 'c1', nick = 'alice', oper = false): 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 = oper;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTarget(
|
|
43
|
+
id: string,
|
|
44
|
+
nick: string,
|
|
45
|
+
opts: { oper?: boolean; invisible?: boolean } = {},
|
|
46
|
+
): ConnectionState {
|
|
47
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
48
|
+
s.nick = nick;
|
|
49
|
+
s.user = nick;
|
|
50
|
+
s.host = 'example.com';
|
|
51
|
+
s.realname = 'Real Name';
|
|
52
|
+
s.registration = 'registered';
|
|
53
|
+
if (opts.oper !== undefined) s.userModes.oper = opts.oper;
|
|
54
|
+
if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
|
|
55
|
+
return s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const L = (text: string): RawLine => ({ text });
|
|
59
|
+
|
|
60
|
+
const trace = (target?: string) =>
|
|
61
|
+
({
|
|
62
|
+
command: 'TRACE',
|
|
63
|
+
params: target === undefined ? [] : [target],
|
|
64
|
+
tags: {},
|
|
65
|
+
}) as const;
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// traceReducer — no target
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
describe('traceReducer — no target', () => {
|
|
72
|
+
it('emits the 262 terminator with no middle when there is no target', () => {
|
|
73
|
+
const requester = makeConn();
|
|
74
|
+
const ctx = makeCtx(requester);
|
|
75
|
+
|
|
76
|
+
const out = traceReducer(undefined, trace(), ctx);
|
|
77
|
+
|
|
78
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
79
|
+
Effect.send('c1', [L(':irc.example.com 262 alice :End of TRACE')]),
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('omits any 200/204/205 detail line when there is no target', () => {
|
|
84
|
+
const requester = makeConn();
|
|
85
|
+
const ctx = makeCtx(requester);
|
|
86
|
+
|
|
87
|
+
const out = traceReducer(undefined, trace(), ctx);
|
|
88
|
+
|
|
89
|
+
const text = out.effects
|
|
90
|
+
.map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
|
|
91
|
+
.join('|');
|
|
92
|
+
expect(text).not.toContain(' 200 ');
|
|
93
|
+
expect(text).not.toContain(' 204 ');
|
|
94
|
+
expect(text).not.toContain(' 205 ');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('updates the requester lastSeen to ctx.clock.now()', () => {
|
|
98
|
+
const requester = makeConn();
|
|
99
|
+
expect(requester.lastSeen).toBe(0);
|
|
100
|
+
const ctx = makeCtx(requester);
|
|
101
|
+
|
|
102
|
+
traceReducer(undefined, trace(), ctx);
|
|
103
|
+
|
|
104
|
+
expect(requester.lastSeen).toBe(5_000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('uses "*" as the nick when the requester has none', () => {
|
|
108
|
+
const requester = createConnection({ id: 'c1', connectedSince: 0 });
|
|
109
|
+
const ctx = makeCtx(requester);
|
|
110
|
+
|
|
111
|
+
const out = traceReducer(undefined, trace(), ctx);
|
|
112
|
+
|
|
113
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
114
|
+
Effect.send('c1', [L(':irc.example.com 262 * :End of TRACE')]),
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// traceReducer — non-oper detail suppression
|
|
121
|
+
// ============================================================================
|
|
122
|
+
|
|
123
|
+
describe('traceReducer — non-oper suppression', () => {
|
|
124
|
+
it('emits only the 262 terminator for a non-oper TRACE <nick> (no leak)', () => {
|
|
125
|
+
const requester = makeConn('c1', 'alice', false);
|
|
126
|
+
const ctx = makeCtx(requester);
|
|
127
|
+
const target = makeTarget('c2', 'bob');
|
|
128
|
+
|
|
129
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
130
|
+
|
|
131
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
132
|
+
Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does not emit a 205 detail line for a non-oper requester', () => {
|
|
137
|
+
const requester = makeConn('c1', 'alice', false);
|
|
138
|
+
const ctx = makeCtx(requester);
|
|
139
|
+
const target = makeTarget('c2', 'bob');
|
|
140
|
+
|
|
141
|
+
const out = traceReducer(target, trace('bob'), 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(' 205 ');
|
|
147
|
+
expect(text).not.toContain(' 204 ');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ============================================================================
|
|
152
|
+
// traceReducer — oper detail
|
|
153
|
+
// ============================================================================
|
|
154
|
+
|
|
155
|
+
describe('traceReducer — oper detail', () => {
|
|
156
|
+
it('emits a 205 RPL_TRACEUSER line for an oper tracing a normal user', () => {
|
|
157
|
+
const requester = makeConn('c1', 'alice', true);
|
|
158
|
+
const ctx = makeCtx(requester);
|
|
159
|
+
const target = makeTarget('c2', 'bob');
|
|
160
|
+
|
|
161
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
162
|
+
|
|
163
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
164
|
+
Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
|
|
165
|
+
Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('emits a 204 RPL_TRACEOPERATOR line for an oper tracing another oper', () => {
|
|
170
|
+
const requester = makeConn('c1', 'alice', true);
|
|
171
|
+
const ctx = makeCtx(requester);
|
|
172
|
+
const target = makeTarget('c2', 'bob', { oper: true });
|
|
173
|
+
|
|
174
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
175
|
+
|
|
176
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
177
|
+
Effect.send('c1', [L(':irc.example.com 204 alice Oper 0 bob bob example.com')]),
|
|
178
|
+
Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('uses "*" nick fallbacks in 204/205 lines for an oper without a nick', () => {
|
|
183
|
+
const requester = createConnection({ id: 'c1', connectedSince: 0 });
|
|
184
|
+
requester.userModes.oper = true;
|
|
185
|
+
const ctx = makeCtx(requester);
|
|
186
|
+
const target = makeTarget('c2', 'bob');
|
|
187
|
+
|
|
188
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
189
|
+
|
|
190
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
191
|
+
Effect.send('c1', [L(':irc.example.com 205 * User 0 bob bob example.com')]),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('uses "?" / "*" fallbacks in 205 line when target lacks user/host/nick', () => {
|
|
196
|
+
const requester = makeConn('c1', 'alice', true);
|
|
197
|
+
const ctx = makeCtx(requester);
|
|
198
|
+
const target = createConnection({ id: 'c2', connectedSince: 0 });
|
|
199
|
+
target.registration = 'registered';
|
|
200
|
+
|
|
201
|
+
const out = traceReducer(target, trace('ghost'), ctx);
|
|
202
|
+
|
|
203
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
204
|
+
Effect.send('c1', [L(':irc.example.com 205 alice User 0 * ? ?')]),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// traceReducer — +i invisible filtering
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
describe('traceReducer — +i invisible filtering', () => {
|
|
214
|
+
it('omits detail for an invisible target a non-oper does not share a channel with', () => {
|
|
215
|
+
const requester = makeConn('c1', 'alice', false);
|
|
216
|
+
const ctx = makeCtx(requester);
|
|
217
|
+
const target = makeTarget('c2', 'bob', { invisible: true });
|
|
218
|
+
|
|
219
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
220
|
+
|
|
221
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
222
|
+
Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('shows a 205 line for an invisible target when the requester is an oper (oper bypasses +i)', () => {
|
|
227
|
+
const requester = makeConn('c1', 'alice', true);
|
|
228
|
+
const ctx = makeCtx(requester);
|
|
229
|
+
const target = makeTarget('c2', 'bob', { invisible: true });
|
|
230
|
+
|
|
231
|
+
const out = traceReducer(target, trace('bob'), ctx);
|
|
232
|
+
|
|
233
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
234
|
+
Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// traceReducer — remote server target
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
describe('traceReducer — remote server target', () => {
|
|
244
|
+
it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
|
|
245
|
+
const requester = makeConn('c1', 'alice', true);
|
|
246
|
+
const ctx = makeCtx(requester);
|
|
247
|
+
|
|
248
|
+
const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
|
|
249
|
+
|
|
250
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
251
|
+
Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('uses "*" nick in 402 when the requester has none', () => {
|
|
256
|
+
const requester = createConnection({ id: 'c1', connectedSince: 0 });
|
|
257
|
+
const ctx = makeCtx(requester);
|
|
258
|
+
|
|
259
|
+
const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
|
|
260
|
+
|
|
261
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
262
|
+
Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// traceReducer — unknown nick (actor could not resolve)
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
describe('traceReducer — unknown nick', () => {
|
|
272
|
+
it('emits 262 with the requested nick when target resolves to undefined', () => {
|
|
273
|
+
const requester = makeConn('c1', 'alice', true);
|
|
274
|
+
const ctx = makeCtx(requester);
|
|
275
|
+
|
|
276
|
+
const out = traceReducer(undefined, trace('ghost'), ctx);
|
|
277
|
+
|
|
278
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
279
|
+
Effect.send('c1', [L(':irc.example.com 262 alice ghost :End of TRACE')]),
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { wallopsReducer } from '../../src/commands/wallops';
|
|
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
|
+
const L = (text: string): RawLine => ({ text });
|
|
43
|
+
|
|
44
|
+
const wallops = (message?: string) =>
|
|
45
|
+
({
|
|
46
|
+
command: 'WALLOPS',
|
|
47
|
+
params: message === undefined ? [] : [message],
|
|
48
|
+
tags: {},
|
|
49
|
+
}) as const;
|
|
50
|
+
|
|
51
|
+
describe('wallopsReducer — oper happy path', () => {
|
|
52
|
+
it('emits a global BroadcastWallops effect carrying the oper source prefix', () => {
|
|
53
|
+
const oper = makeOper();
|
|
54
|
+
const ctx = makeCtx(oper);
|
|
55
|
+
|
|
56
|
+
const out = wallopsReducer(oper, wallops('hello'), ctx);
|
|
57
|
+
|
|
58
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
59
|
+
Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :hello')], 'c1'),
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('passes the message text through verbatim (multi-word)', () => {
|
|
64
|
+
const oper = makeOper();
|
|
65
|
+
const ctx = makeCtx(oper);
|
|
66
|
+
|
|
67
|
+
const out = wallopsReducer(oper, wallops('restart in 5 minutes'), ctx);
|
|
68
|
+
|
|
69
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
70
|
+
Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :restart in 5 minutes')], 'c1'),
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('defaults except to the sender (skip-self, NOTICE/PRIVMSG semantics)', () => {
|
|
75
|
+
const oper = makeOper();
|
|
76
|
+
const ctx = makeCtx(oper);
|
|
77
|
+
|
|
78
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
79
|
+
|
|
80
|
+
expect(out.effects[0]?.tag).toBe('BroadcastWallops');
|
|
81
|
+
// except is the oper's own connection id, so the runtime never echoes
|
|
82
|
+
// the wallops back to its originator.
|
|
83
|
+
expect((out.effects[0] as { except?: string }).except).toBe('c1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('updates the oper lastSeen to ctx.clock.now()', () => {
|
|
87
|
+
const oper = makeOper();
|
|
88
|
+
expect(oper.lastSeen).toBe(0);
|
|
89
|
+
const ctx = makeCtx(oper);
|
|
90
|
+
|
|
91
|
+
wallopsReducer(oper, wallops('hi'), ctx);
|
|
92
|
+
|
|
93
|
+
expect(oper.lastSeen).toBe(5_000);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns the same state reference unchanged', () => {
|
|
97
|
+
const oper = makeOper();
|
|
98
|
+
const ctx = makeCtx(oper);
|
|
99
|
+
|
|
100
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
101
|
+
|
|
102
|
+
expect(out.state).toBe(oper);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('falls back to the bare nick when the hostmask is incomplete (no user/host)', () => {
|
|
106
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
107
|
+
oper.nick = 'alice';
|
|
108
|
+
oper.registration = 'registered';
|
|
109
|
+
oper.userModes.oper = true;
|
|
110
|
+
const ctx = makeCtx(oper);
|
|
111
|
+
|
|
112
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
113
|
+
|
|
114
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
115
|
+
Effect.broadcastWallops([L(':alice WALLOPS :hi')], 'c1'),
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('falls back to "*" when the oper has neither nick nor hostmask (defensive)', () => {
|
|
120
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
121
|
+
oper.registration = 'registered';
|
|
122
|
+
oper.userModes.oper = true;
|
|
123
|
+
const ctx = makeCtx(oper);
|
|
124
|
+
|
|
125
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
126
|
+
|
|
127
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
128
|
+
Effect.broadcastWallops([L(':* WALLOPS :hi')], 'c1'),
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('wallopsReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
|
|
134
|
+
it('emits 481 when the sender is not an oper', () => {
|
|
135
|
+
const oper = makeOper();
|
|
136
|
+
oper.userModes.oper = false;
|
|
137
|
+
const ctx = makeCtx(oper);
|
|
138
|
+
|
|
139
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
140
|
+
|
|
141
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
142
|
+
Effect.send('c1', [
|
|
143
|
+
L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
|
|
144
|
+
]),
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not emit a BroadcastWallops when the sender is not an oper', () => {
|
|
149
|
+
const oper = makeOper();
|
|
150
|
+
oper.userModes.oper = false;
|
|
151
|
+
const ctx = makeCtx(oper);
|
|
152
|
+
|
|
153
|
+
const out = wallopsReducer(oper, wallops('hi'), ctx);
|
|
154
|
+
|
|
155
|
+
expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('gates before param validation: a non-oper with no message still gets 481', () => {
|
|
159
|
+
const oper = makeOper();
|
|
160
|
+
oper.userModes.oper = false;
|
|
161
|
+
const ctx = makeCtx(oper);
|
|
162
|
+
|
|
163
|
+
const out = wallopsReducer(oper, wallops(), 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 = wallopsReducer(oper, wallops('hi'), 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('wallopsReducer — empty message (412 ERR_NOTEXTTOSEND)', () => {
|
|
188
|
+
it('emits 412 when no message parameter is supplied', () => {
|
|
189
|
+
const oper = makeOper();
|
|
190
|
+
const ctx = makeCtx(oper);
|
|
191
|
+
|
|
192
|
+
const out = wallopsReducer(oper, wallops(), ctx);
|
|
193
|
+
|
|
194
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
195
|
+
Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('emits 412 when the message is the empty string', () => {
|
|
200
|
+
const oper = makeOper();
|
|
201
|
+
const ctx = makeCtx(oper);
|
|
202
|
+
|
|
203
|
+
const out = wallopsReducer(oper, wallops(''), ctx);
|
|
204
|
+
|
|
205
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
206
|
+
Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not broadcast when the message is empty', () => {
|
|
211
|
+
const oper = makeOper();
|
|
212
|
+
const ctx = makeCtx(oper);
|
|
213
|
+
|
|
214
|
+
const out = wallopsReducer(oper, wallops(''), ctx);
|
|
215
|
+
|
|
216
|
+
expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('uses "*" as the nick in 412 when the connection has no nick', () => {
|
|
220
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
221
|
+
oper.registration = 'registered';
|
|
222
|
+
oper.userModes.oper = true;
|
|
223
|
+
const ctx = makeCtx(oper);
|
|
224
|
+
|
|
225
|
+
const out = wallopsReducer(oper, wallops(''), ctx);
|
|
226
|
+
|
|
227
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
228
|
+
Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guards for the formally-dropped S2S and obsolete RFC 2812 verbs.
|
|
3
|
+
*
|
|
4
|
+
* - S2S drop: `CONNECT` / `SQUIT` / `LINKS` are server-to-server commands.
|
|
5
|
+
* S2S is an explicit PLAN non-goal (`PLAN.md` §1), so the three verbs are
|
|
6
|
+
* formally dropped (they still return `421 ERR_UNKNOWNCOMMAND` — that is
|
|
7
|
+
* the correct response for an unimplemented command). Their reserved
|
|
8
|
+
* numerics (`RPL_LINKS`, `RPL_ENDOFLINKS`, `ERR_CANTKILLSERVER`) are removed
|
|
9
|
+
* here.
|
|
10
|
+
*
|
|
11
|
+
* - Obsolete-verb drop: `SERVICE` / `SUMMON` / `USERS` are RFC 2812 verbs
|
|
12
|
+
* that were never widely implemented and are effectively dead. They are
|
|
13
|
+
* formally dropped (also still `421`). Their reserved numerics
|
|
14
|
+
* (`RPL_YOURESERVICE`, `ERR_NOSUCHSERVICE`, `ERR_SUMMONDISABLED`,
|
|
15
|
+
* `ERR_USERSDISABLED`) are removed here.
|
|
16
|
+
*
|
|
17
|
+
* Because the drop is "remove the numeric, keep the 421", there is no runtime
|
|
18
|
+
* behaviour to assert — these are source-level / registry-level regression
|
|
19
|
+
* guards that prevent the numerics (and the "deferred verb" framing) from
|
|
20
|
+
* creeping back in.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from 'vitest';
|
|
24
|
+
import { Numerics, numericToName } from '../src/protocol/numerics';
|
|
25
|
+
import numericsSource from '../src/protocol/numerics.ts?raw';
|
|
26
|
+
|
|
27
|
+
describe('dropped S2S verbs (CONNECT / SQUIT / LINKS)', () => {
|
|
28
|
+
// S2S is a PLAN non-goal; the three verbs are formally dropped. Their
|
|
29
|
+
// reserved numerics are removed from the registry.
|
|
30
|
+
const droppedS2SNumerics = ['RPL_LINKS', 'RPL_ENDOFLINKS', 'ERR_CANTKILLSERVER'] as const;
|
|
31
|
+
|
|
32
|
+
it.each(droppedS2SNumerics)('numerics.ts source no longer defines %s', (name) => {
|
|
33
|
+
expect(numericsSource).not.toContain(`${name}:`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each(droppedS2SNumerics)('Numerics runtime object has no %s key', (name) => {
|
|
37
|
+
expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('numericToName no longer resolves the dropped S2S codes (364, 365, 483)', () => {
|
|
41
|
+
expect(numericToName.get(364)).toBeUndefined();
|
|
42
|
+
expect(numericToName.get(365)).toBeUndefined();
|
|
43
|
+
expect(numericToName.get(483)).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('numerics.ts source documents the S2S drop', () => {
|
|
47
|
+
// The drop must be explained where the numerics used to live, so the
|
|
48
|
+
// absence is not mistaken for a gap.
|
|
49
|
+
expect(numericsSource).toMatch(/S2S/);
|
|
50
|
+
expect(numericsSource).toMatch(/PLAN non-goal/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('ERR_NOSUCHSERVER (402) is retained (used by LUSERS/STATS/TRACE)', () => {
|
|
54
|
+
// 402 stays because the implemented query verbs use it for remote-server
|
|
55
|
+
// gating.
|
|
56
|
+
expect(Numerics.ERR_NOSUCHSERVER).toBe(402);
|
|
57
|
+
expect(numericToName.get(402)).toBe('ERR_NOSUCHSERVER');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS)', () => {
|
|
62
|
+
// RFC 2812 obsolete verbs, never widely implemented. Formally dropped;
|
|
63
|
+
// their reserved numerics are removed from the registry.
|
|
64
|
+
const droppedObsoleteNumerics = [
|
|
65
|
+
'RPL_YOURESERVICE',
|
|
66
|
+
'ERR_NOSUCHSERVICE',
|
|
67
|
+
'ERR_SUMMONDISABLED',
|
|
68
|
+
'ERR_USERSDISABLED',
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
it.each(droppedObsoleteNumerics)('numerics.ts source no longer defines %s', (name) => {
|
|
72
|
+
expect(numericsSource).not.toContain(`${name}:`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it.each(droppedObsoleteNumerics)('Numerics runtime object has no %s key', (name) => {
|
|
76
|
+
expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('numericToName no longer resolves the dropped obsolete codes (383, 408, 445, 446)', () => {
|
|
80
|
+
expect(numericToName.get(383)).toBeUndefined();
|
|
81
|
+
expect(numericToName.get(408)).toBeUndefined();
|
|
82
|
+
expect(numericToName.get(445)).toBeUndefined();
|
|
83
|
+
expect(numericToName.get(446)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('numerics.ts source documents the obsolete-verb drop', () => {
|
|
87
|
+
expect(numericsSource).toMatch(/RFC 2812/);
|
|
88
|
+
expect(numericsSource).toMatch(/obsolete/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -82,6 +82,18 @@ describe('Effect constructors', () => {
|
|
|
82
82
|
notFoundLines: [{ text: '401' }],
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it('BroadcastWallops carries lines and optional except', () => {
|
|
87
|
+
expect(Effect.broadcastWallops([line(':a WALLOPS :hi')])).toEqual<EffectType>({
|
|
88
|
+
tag: 'BroadcastWallops',
|
|
89
|
+
lines: [{ text: ':a WALLOPS :hi' }],
|
|
90
|
+
});
|
|
91
|
+
expect(Effect.broadcastWallops([line(':a WALLOPS :hi')], 'c1')).toEqual<EffectType>({
|
|
92
|
+
tag: 'BroadcastWallops',
|
|
93
|
+
lines: [{ text: ':a WALLOPS :hi' }],
|
|
94
|
+
except: 'c1',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
85
97
|
});
|
|
86
98
|
|
|
87
99
|
describe('Effect tag set', () => {
|
|
@@ -95,6 +107,7 @@ describe('Effect tag set', () => {
|
|
|
95
107
|
'ReleaseNick',
|
|
96
108
|
'ApplyChannelDelta',
|
|
97
109
|
'SendToNick',
|
|
110
|
+
'BroadcastWallops',
|
|
98
111
|
];
|
|
99
112
|
|
|
100
113
|
it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
|
|
@@ -107,6 +120,7 @@ describe('Effect tag set', () => {
|
|
|
107
120
|
ReleaseNick: Effect.releaseNick('n'),
|
|
108
121
|
ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
|
|
109
122
|
SendToNick: Effect.sendToNick('n', 'c', []),
|
|
123
|
+
BroadcastWallops: Effect.broadcastWallops([]),
|
|
110
124
|
};
|
|
111
125
|
expect(constructors[tag].tag).toBe(tag);
|
|
112
126
|
});
|