serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
adminReducer,
|
|
4
|
+
infoReducer,
|
|
5
|
+
timeReducer,
|
|
6
|
+
versionReducer,
|
|
7
|
+
} from '../../src/commands/server-info';
|
|
8
|
+
import { DEFAULT_SERVER_VERSION } from '../../src/config';
|
|
9
|
+
import { Effect } from '../../src/effects';
|
|
10
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
11
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
12
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
13
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
14
|
+
|
|
15
|
+
const baseServerConfig: ServerConfig = {
|
|
16
|
+
serverName: 'irc.example.com',
|
|
17
|
+
networkName: 'ExampleNet',
|
|
18
|
+
maxChannelsPerUser: 30,
|
|
19
|
+
maxTargetsPerCommand: 10,
|
|
20
|
+
maxListEntries: 50,
|
|
21
|
+
nickLen: 30,
|
|
22
|
+
channelLen: 50,
|
|
23
|
+
topicLen: 390,
|
|
24
|
+
quitMessage: 'Client Quit',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function makeCtx(state: ConnectionState, clockMs = 5_000): Ctx {
|
|
28
|
+
return buildCtx({
|
|
29
|
+
serverConfig: baseServerConfig,
|
|
30
|
+
clock: new FakeClock(clockMs),
|
|
31
|
+
ids: new SequentialIdFactory(),
|
|
32
|
+
motd: EmptyMotdProvider,
|
|
33
|
+
connection: state,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeState(): ConnectionState {
|
|
38
|
+
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
39
|
+
s.nick = 'alice';
|
|
40
|
+
s.user = 'alice';
|
|
41
|
+
s.host = 'example.com';
|
|
42
|
+
s.realname = 'Alice';
|
|
43
|
+
s.registration = 'registered';
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const L = (text: string): RawLine => ({ text });
|
|
48
|
+
|
|
49
|
+
const version = (server?: string) =>
|
|
50
|
+
({ command: 'VERSION', params: server === undefined ? [] : [server], tags: {} }) as const;
|
|
51
|
+
|
|
52
|
+
const time = (server?: string) =>
|
|
53
|
+
({ command: 'TIME', params: server === undefined ? [] : [server], tags: {} }) as const;
|
|
54
|
+
|
|
55
|
+
const admin = (server?: string) =>
|
|
56
|
+
({ command: 'ADMIN', params: server === undefined ? [] : [server], tags: {} }) as const;
|
|
57
|
+
|
|
58
|
+
const info = (server?: string) =>
|
|
59
|
+
({ command: 'INFO', params: server === undefined ? [] : [server], tags: {} }) as const;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// VERSION
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('versionReducer', () => {
|
|
66
|
+
it('emits 351 RPL_VERSION with the server version and name', () => {
|
|
67
|
+
const state = makeState();
|
|
68
|
+
const ctx = makeCtx(state);
|
|
69
|
+
|
|
70
|
+
const out = versionReducer(state, version(), ctx);
|
|
71
|
+
|
|
72
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
73
|
+
Effect.send('c1', [
|
|
74
|
+
L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
|
|
75
|
+
]),
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
80
|
+
const state = makeState();
|
|
81
|
+
const ctx = makeCtx(state, 9_999);
|
|
82
|
+
|
|
83
|
+
versionReducer(state, version(), ctx);
|
|
84
|
+
|
|
85
|
+
expect(state.lastSeen).toBe(9_999);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
89
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
90
|
+
const ctx = makeCtx(state);
|
|
91
|
+
|
|
92
|
+
const out = versionReducer(state, version(), ctx);
|
|
93
|
+
|
|
94
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
95
|
+
Effect.send('c1', [
|
|
96
|
+
L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('ignores the optional server target parameter (single-server deployment)', () => {
|
|
102
|
+
const state = makeState();
|
|
103
|
+
const ctx = makeCtx(state);
|
|
104
|
+
|
|
105
|
+
const out = versionReducer(state, version('other.server'), ctx);
|
|
106
|
+
|
|
107
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
108
|
+
Effect.send('c1', [
|
|
109
|
+
L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns the same state reference', () => {
|
|
115
|
+
const state = makeState();
|
|
116
|
+
const ctx = makeCtx(state);
|
|
117
|
+
|
|
118
|
+
const out = versionReducer(state, version(), ctx);
|
|
119
|
+
|
|
120
|
+
expect(out.state).toBe(state);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// TIME
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe('timeReducer', () => {
|
|
129
|
+
it('emits 391 RPL_TIME with a human-readable timestamp from the clock', () => {
|
|
130
|
+
const state = makeState();
|
|
131
|
+
const ctx = makeCtx(state, 1_700_000_000_000);
|
|
132
|
+
|
|
133
|
+
const out = timeReducer(state, time(), ctx);
|
|
134
|
+
|
|
135
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
136
|
+
Effect.send('c1', [
|
|
137
|
+
L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
|
|
138
|
+
]),
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
143
|
+
const state = makeState();
|
|
144
|
+
const ctx = makeCtx(state, 42_000);
|
|
145
|
+
|
|
146
|
+
timeReducer(state, time(), ctx);
|
|
147
|
+
|
|
148
|
+
expect(state.lastSeen).toBe(42_000);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
152
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
153
|
+
const ctx = makeCtx(state, 1_700_000_000_000);
|
|
154
|
+
|
|
155
|
+
const out = timeReducer(state, time(), ctx);
|
|
156
|
+
|
|
157
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
158
|
+
Effect.send('c1', [L(':irc.example.com 391 * irc.example.com :2023-11-14T22:13:20.000Z')]),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('ignores the optional server target parameter', () => {
|
|
163
|
+
const state = makeState();
|
|
164
|
+
const ctx = makeCtx(state, 1_700_000_000_000);
|
|
165
|
+
|
|
166
|
+
const out = timeReducer(state, time('elsewhere'), ctx);
|
|
167
|
+
|
|
168
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
169
|
+
Effect.send('c1', [
|
|
170
|
+
L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
|
|
171
|
+
]),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// ADMIN
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe('adminReducer', () => {
|
|
181
|
+
it('emits 256 RPL_ADMINME with the server name', () => {
|
|
182
|
+
const state = makeState();
|
|
183
|
+
const ctx = makeCtx(state);
|
|
184
|
+
|
|
185
|
+
const out = adminReducer(state, admin(), ctx);
|
|
186
|
+
|
|
187
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
188
|
+
Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
193
|
+
const state = makeState();
|
|
194
|
+
const ctx = makeCtx(state, 7_777);
|
|
195
|
+
|
|
196
|
+
adminReducer(state, admin(), ctx);
|
|
197
|
+
|
|
198
|
+
expect(state.lastSeen).toBe(7_777);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
202
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
203
|
+
const ctx = makeCtx(state);
|
|
204
|
+
|
|
205
|
+
const out = adminReducer(state, admin(), ctx);
|
|
206
|
+
|
|
207
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
208
|
+
Effect.send('c1', [L(':irc.example.com 256 * irc.example.com :Administrative info')]),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('ignores the optional server target parameter', () => {
|
|
213
|
+
const state = makeState();
|
|
214
|
+
const ctx = makeCtx(state);
|
|
215
|
+
|
|
216
|
+
const out = adminReducer(state, admin('foo'), ctx);
|
|
217
|
+
|
|
218
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
219
|
+
Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// INFO
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe('infoReducer', () => {
|
|
229
|
+
it('emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO', () => {
|
|
230
|
+
const state = makeState();
|
|
231
|
+
const ctx = makeCtx(state);
|
|
232
|
+
|
|
233
|
+
const out = infoReducer(state, info(), ctx);
|
|
234
|
+
|
|
235
|
+
const lines = (out.effects[0] as { lines: RawLine[] }).lines;
|
|
236
|
+
expect(lines[0]).toEqual(
|
|
237
|
+
L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),
|
|
238
|
+
);
|
|
239
|
+
expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
|
|
240
|
+
expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('includes at least one 371 line and the 374 terminator', () => {
|
|
244
|
+
const state = makeState();
|
|
245
|
+
const ctx = makeCtx(state);
|
|
246
|
+
|
|
247
|
+
const out = infoReducer(state, info(), ctx);
|
|
248
|
+
|
|
249
|
+
const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
|
|
250
|
+
const rpl371Count = texts.filter((t) => / 371 /.test(t)).length;
|
|
251
|
+
const rpl374Count = texts.filter((t) => / 374 /.test(t)).length;
|
|
252
|
+
expect(rpl371Count).toBeGreaterThanOrEqual(1);
|
|
253
|
+
expect(rpl374Count).toBe(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
257
|
+
const state = makeState();
|
|
258
|
+
const ctx = makeCtx(state, 3_333);
|
|
259
|
+
|
|
260
|
+
infoReducer(state, info(), ctx);
|
|
261
|
+
|
|
262
|
+
expect(state.lastSeen).toBe(3_333);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
266
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
267
|
+
const ctx = makeCtx(state);
|
|
268
|
+
|
|
269
|
+
const out = infoReducer(state, info(), ctx);
|
|
270
|
+
|
|
271
|
+
const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
|
|
272
|
+
expect(texts.every((t) => t.includes(' * '))).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -102,11 +102,7 @@ describe('tagmsgChannelReducer — success', () => {
|
|
|
102
102
|
const conn = makeConn();
|
|
103
103
|
const ctx = makeCtx(conn);
|
|
104
104
|
|
|
105
|
-
const out = tagmsgChannelReducer(
|
|
106
|
-
chan,
|
|
107
|
-
{ command: 'TAGMSG', params: ['#foo'], tags: {} },
|
|
108
|
-
ctx,
|
|
109
|
-
);
|
|
105
|
+
const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
|
|
110
106
|
|
|
111
107
|
expect(out.effects).toEqual<EffectType[]>([
|
|
112
108
|
Effect.broadcast('#foo', [L(':alice!alice@example.com TAGMSG #foo')], 'c1', 'message-tags'),
|
|
@@ -516,11 +512,7 @@ describe('tagmsgChannelReducer — defensive hostmask', () => {
|
|
|
516
512
|
conn.caps.add('message-tags');
|
|
517
513
|
const ctx = makeCtx(conn);
|
|
518
514
|
|
|
519
|
-
const out = tagmsgChannelReducer(
|
|
520
|
-
chan,
|
|
521
|
-
{ command: 'TAGMSG', params: ['#foo'], tags: {} },
|
|
522
|
-
ctx,
|
|
523
|
-
);
|
|
515
|
+
const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
|
|
524
516
|
|
|
525
517
|
const broadcast = out.effects[0];
|
|
526
518
|
expect(broadcast?.tag).toBe('Broadcast');
|
|
@@ -535,11 +527,7 @@ describe('tagmsgChannelReducer — defensive hostmask', () => {
|
|
|
535
527
|
conn.caps.add('message-tags');
|
|
536
528
|
const ctx = makeCtx(conn);
|
|
537
529
|
|
|
538
|
-
const out = tagmsgChannelReducer(
|
|
539
|
-
chan,
|
|
540
|
-
{ command: 'TAGMSG', params: ['#foo'], tags: {} },
|
|
541
|
-
ctx,
|
|
542
|
-
);
|
|
530
|
+
const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
|
|
543
531
|
|
|
544
532
|
const broadcast = out.effects[0];
|
|
545
533
|
expect(broadcast?.tag).toBe('Broadcast');
|
|
@@ -564,11 +552,7 @@ describe('tagmsgUserReducer', () => {
|
|
|
564
552
|
);
|
|
565
553
|
|
|
566
554
|
expect(out.effects).toEqual<EffectType[]>([
|
|
567
|
-
Effect.sendToNick(
|
|
568
|
-
'bob',
|
|
569
|
-
'c1',
|
|
570
|
-
[L('@+typing=active :alice!alice@example.com TAGMSG bob')],
|
|
571
|
-
),
|
|
555
|
+
Effect.sendToNick('bob', 'c1', [L('@+typing=active :alice!alice@example.com TAGMSG bob')]),
|
|
572
556
|
]);
|
|
573
557
|
});
|
|
574
558
|
|
|
@@ -583,11 +567,9 @@ describe('tagmsgUserReducer', () => {
|
|
|
583
567
|
);
|
|
584
568
|
|
|
585
569
|
expect(out.effects).toEqual<EffectType[]>([
|
|
586
|
-
Effect.sendToNick(
|
|
587
|
-
'nobody',
|
|
588
|
-
|
|
589
|
-
[L('@+typing=active :alice!alice@example.com TAGMSG nobody')],
|
|
590
|
-
),
|
|
570
|
+
Effect.sendToNick('nobody', 'c1', [
|
|
571
|
+
L('@+typing=active :alice!alice@example.com TAGMSG nobody'),
|
|
572
|
+
]),
|
|
591
573
|
]);
|
|
592
574
|
});
|
|
593
575
|
|
|
@@ -642,11 +624,7 @@ describe('tagmsgUserReducer', () => {
|
|
|
642
624
|
const conn = makeConn();
|
|
643
625
|
const ctx = makeCtx(conn);
|
|
644
626
|
|
|
645
|
-
const out = tagmsgUserReducer(
|
|
646
|
-
conn,
|
|
647
|
-
{ command: 'TAGMSG', params: ['bob'], tags: {} },
|
|
648
|
-
ctx,
|
|
649
|
-
);
|
|
627
|
+
const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
|
|
650
628
|
|
|
651
629
|
expect(out.effects).toEqual<EffectType[]>([
|
|
652
630
|
Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com TAGMSG bob')]),
|
|
@@ -667,11 +645,7 @@ describe('tagmsgUserReducer', () => {
|
|
|
667
645
|
const conn = makeConn();
|
|
668
646
|
const ctx = makeCtx(conn);
|
|
669
647
|
|
|
670
|
-
const out = tagmsgUserReducer(
|
|
671
|
-
conn,
|
|
672
|
-
{ command: 'TAGMSG', params: ['bob'], tags: {} },
|
|
673
|
-
ctx,
|
|
674
|
-
);
|
|
648
|
+
const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
|
|
675
649
|
|
|
676
650
|
expect(out.state).toBe(conn);
|
|
677
651
|
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatUserhostReply, userhostReducer } from '../../src/commands/userhost';
|
|
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 {
|
|
7
|
+
type ConnSnapshot,
|
|
8
|
+
type ConnectionState,
|
|
9
|
+
createConnection,
|
|
10
|
+
toSnapshot,
|
|
11
|
+
} from '../../src/state/connection';
|
|
12
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
13
|
+
|
|
14
|
+
const baseServerConfig: 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(state: ConnectionState): Ctx {
|
|
27
|
+
return buildCtx({
|
|
28
|
+
serverConfig: baseServerConfig,
|
|
29
|
+
clock: new FakeClock(5_000),
|
|
30
|
+
ids: new SequentialIdFactory(),
|
|
31
|
+
motd: EmptyMotdProvider,
|
|
32
|
+
connection: state,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeState(): ConnectionState {
|
|
37
|
+
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
38
|
+
s.nick = 'alice';
|
|
39
|
+
s.user = 'alice';
|
|
40
|
+
s.host = 'example.com';
|
|
41
|
+
s.realname = 'Alice';
|
|
42
|
+
s.registration = 'registered';
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeSnapshot(
|
|
47
|
+
nick: string,
|
|
48
|
+
user: string,
|
|
49
|
+
host: string,
|
|
50
|
+
opts?: { away?: string; oper?: boolean },
|
|
51
|
+
): ConnSnapshot {
|
|
52
|
+
const s = createConnection({ id: `c-${nick}`, connectedSince: 0 });
|
|
53
|
+
s.nick = nick;
|
|
54
|
+
s.user = user;
|
|
55
|
+
s.host = host;
|
|
56
|
+
s.registration = 'registered';
|
|
57
|
+
if (opts?.away !== undefined) s.away = opts.away;
|
|
58
|
+
if (opts?.oper) s.userModes.oper = true;
|
|
59
|
+
return toSnapshot(s);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const L = (text: string): RawLine => ({ text });
|
|
63
|
+
|
|
64
|
+
const userhost = (...nicks: string[]) =>
|
|
65
|
+
({ command: 'USERHOST', params: nicks, tags: {} }) as const;
|
|
66
|
+
|
|
67
|
+
describe('userhostReducer', () => {
|
|
68
|
+
it('emits 302 RPL_USERHOST with the resolved hostmask for one nick', () => {
|
|
69
|
+
const state = makeState();
|
|
70
|
+
const ctx = makeCtx(state);
|
|
71
|
+
const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
|
|
72
|
+
|
|
73
|
+
const out = userhostReducer(resolved, userhost('bob'), ctx);
|
|
74
|
+
|
|
75
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
76
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('includes multiple replies space-separated in one 302 line', () => {
|
|
81
|
+
const state = makeState();
|
|
82
|
+
const ctx = makeCtx(state);
|
|
83
|
+
const resolved = new Map([
|
|
84
|
+
['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],
|
|
85
|
+
['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);
|
|
89
|
+
|
|
90
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
91
|
+
Effect.send('c1', [
|
|
92
|
+
L(':irc.example.com 302 alice :bob=+bobuser@bobhost carol=+caroluser@carolhost'),
|
|
93
|
+
]),
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('marks away users with - instead of +', () => {
|
|
98
|
+
const state = makeState();
|
|
99
|
+
const ctx = makeCtx(state);
|
|
100
|
+
const resolved = new Map([
|
|
101
|
+
['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { away: 'gone' })],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const out = userhostReducer(resolved, userhost('bob'), ctx);
|
|
105
|
+
|
|
106
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
107
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :bob=-bobuser@bobhost')]),
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('prefixes oper users with *', () => {
|
|
112
|
+
const state = makeState();
|
|
113
|
+
const ctx = makeCtx(state);
|
|
114
|
+
const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { oper: true })]]);
|
|
115
|
+
|
|
116
|
+
const out = userhostReducer(resolved, userhost('bob'), ctx);
|
|
117
|
+
|
|
118
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
119
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :bob*=+bobuser@bobhost')]),
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('omits nicks that are offline (null in the resolved map)', () => {
|
|
124
|
+
const state = makeState();
|
|
125
|
+
const ctx = makeCtx(state);
|
|
126
|
+
const resolved = new Map<string, ConnSnapshot | null>([
|
|
127
|
+
['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],
|
|
128
|
+
['ghost', null],
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const out = userhostReducer(resolved, userhost('bob', 'ghost'), ctx);
|
|
132
|
+
|
|
133
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
134
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('emits an empty trailing when no requested nicks are online', () => {
|
|
139
|
+
const state = makeState();
|
|
140
|
+
const ctx = makeCtx(state);
|
|
141
|
+
const resolved = new Map<string, ConnSnapshot | null>([['ghost', null]]);
|
|
142
|
+
|
|
143
|
+
const out = userhostReducer(resolved, userhost('ghost'), ctx);
|
|
144
|
+
|
|
145
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
146
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :')]),
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('emits an empty trailing when no nicks are requested', () => {
|
|
151
|
+
const state = makeState();
|
|
152
|
+
const ctx = makeCtx(state);
|
|
153
|
+
const resolved = new Map<string, ConnSnapshot | null>();
|
|
154
|
+
|
|
155
|
+
const out = userhostReducer(resolved, userhost(), ctx);
|
|
156
|
+
|
|
157
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
158
|
+
Effect.send('c1', [L(':irc.example.com 302 alice :')]),
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
163
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
164
|
+
const ctx = makeCtx(state);
|
|
165
|
+
const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
|
|
166
|
+
|
|
167
|
+
const out = userhostReducer(resolved, userhost('bob'), ctx);
|
|
168
|
+
|
|
169
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
170
|
+
Effect.send('c1', [L(':irc.example.com 302 * :bob=+bobuser@bobhost')]),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
175
|
+
const state = makeState();
|
|
176
|
+
const ctx = makeCtx(state);
|
|
177
|
+
|
|
178
|
+
userhostReducer(new Map(), userhost('bob'), ctx);
|
|
179
|
+
|
|
180
|
+
expect(state.lastSeen).toBe(5_000);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns the same state reference', () => {
|
|
184
|
+
const state = makeState();
|
|
185
|
+
const ctx = makeCtx(state);
|
|
186
|
+
|
|
187
|
+
const out = userhostReducer(new Map(), userhost(), ctx);
|
|
188
|
+
|
|
189
|
+
expect(out.state).toBe(state);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('respects the max-targets cap (caps the number of queried nicks)', () => {
|
|
193
|
+
const state = makeState();
|
|
194
|
+
const config: ServerConfig = { ...baseServerConfig, maxTargetsPerCommand: 2 };
|
|
195
|
+
const ctx = buildCtx({
|
|
196
|
+
serverConfig: config,
|
|
197
|
+
clock: new FakeClock(5_000),
|
|
198
|
+
ids: new SequentialIdFactory(),
|
|
199
|
+
motd: EmptyMotdProvider,
|
|
200
|
+
connection: state,
|
|
201
|
+
});
|
|
202
|
+
const resolved = new Map([
|
|
203
|
+
['bob', makeSnapshot('bob', 'bu', 'bh')],
|
|
204
|
+
['carol', makeSnapshot('carol', 'cu', 'ch')],
|
|
205
|
+
['dave', makeSnapshot('dave', 'du', 'dh')],
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const out = userhostReducer(resolved, userhost('bob', 'carol', 'dave'), ctx);
|
|
209
|
+
|
|
210
|
+
const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
|
|
211
|
+
// Only the first two nicks should appear; the third is beyond the cap.
|
|
212
|
+
expect(text).not.toContain('dave');
|
|
213
|
+
expect(text).toContain('bob');
|
|
214
|
+
expect(text).toContain('carol');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('skips an empty-string nick in the parameter list', () => {
|
|
218
|
+
const state = makeState();
|
|
219
|
+
const ctx = makeCtx(state);
|
|
220
|
+
const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
|
|
221
|
+
|
|
222
|
+
const out = userhostReducer(resolved, userhost('', 'bob'), ctx);
|
|
223
|
+
|
|
224
|
+
const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
|
|
225
|
+
expect(text).toContain('bob=+bobuser@bobhost');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('omits a resolved snapshot that is missing required fields (host absent)', () => {
|
|
229
|
+
const state = makeState();
|
|
230
|
+
const ctx = makeCtx(state);
|
|
231
|
+
const incomplete = makeSnapshot('bob', 'bobuser', 'bobhost');
|
|
232
|
+
(incomplete as { host: string | undefined }).host = undefined;
|
|
233
|
+
const resolved = new Map<string, ConnSnapshot | null>([
|
|
234
|
+
['bob', incomplete],
|
|
235
|
+
['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);
|
|
239
|
+
|
|
240
|
+
const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
|
|
241
|
+
expect(text).not.toContain('bob');
|
|
242
|
+
expect(text).toContain('carol=+caroluser@carolhost');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('formatUserhostReply', () => {
|
|
247
|
+
it('returns null when the snapshot is missing a nick', () => {
|
|
248
|
+
const snap = makeSnapshot('bob', 'bu', 'bh');
|
|
249
|
+
(snap as { nick: string | undefined }).nick = undefined;
|
|
250
|
+
expect(formatUserhostReply(snap)).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns null when the snapshot is missing a user', () => {
|
|
254
|
+
const snap = makeSnapshot('bob', 'bu', 'bh');
|
|
255
|
+
(snap as { user: string | undefined }).user = undefined;
|
|
256
|
+
expect(formatUserhostReply(snap)).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns null when the snapshot is missing a host', () => {
|
|
260
|
+
const snap = makeSnapshot('bob', 'bu', 'bh');
|
|
261
|
+
(snap as { host: string | undefined }).host = undefined;
|
|
262
|
+
expect(formatUserhostReply(snap)).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|