serverless-ircd 0.3.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/.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 +349 -18
- package/README.md +132 -30
- package/apps/aws-stack/README.md +6 -5
- 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 +150 -10
- package/apps/aws-stack/tests/stack.test.ts +145 -4
- 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 +149 -24
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- 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 +75 -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 +96 -9
- package/packages/aws-adapter/src/handlers/default.ts +98 -7
- package/packages/aws-adapter/src/handlers/index.ts +109 -4
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +8 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -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/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +174 -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 +302 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- 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 +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +68 -5
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +278 -85
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +54 -11
- package/packages/cf-adapter/src/index.ts +11 -8
- 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 +12 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
- 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/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/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/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- 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 +15 -8
- 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 +15 -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 +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +24 -3
- 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 +17 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/ison.ts +61 -0
- 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/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- 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/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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +65 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +33 -30
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +360 -12
- package/packages/irc-core/src/protocol/numerics.ts +48 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +46 -2
- 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/account-store.test.ts +45 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- 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/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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- 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/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -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/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +102 -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/types.test.ts +35 -1
- 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-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 +393 -16
- package/packages/irc-server/src/dispatch.ts +1 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +15 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +104 -0
- package/packages/irc-server/tests/actor.test.ts +1489 -4
- package/packages/irc-server/tests/dispatch.test.ts +37 -17
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +5 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -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 +141 -3
- 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 +10 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +9 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +107 -0
- 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
- 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,312 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { whowasReducer } from '../../src/commands/whowas';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import {
|
|
6
|
+
EmptyMotdProvider,
|
|
7
|
+
FakeClock,
|
|
8
|
+
InMemoryNickHistoryStore,
|
|
9
|
+
SequentialIdFactory,
|
|
10
|
+
} from '../../src/ports';
|
|
11
|
+
import { type ConnectionState, 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(conn: ConnectionState, clock = new FakeClock(10_000)): Ctx {
|
|
27
|
+
return buildCtx({
|
|
28
|
+
serverConfig,
|
|
29
|
+
clock,
|
|
30
|
+
ids: new SequentialIdFactory(),
|
|
31
|
+
motd: EmptyMotdProvider,
|
|
32
|
+
connection: conn,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
37
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
38
|
+
s.nick = nick;
|
|
39
|
+
s.user = nick;
|
|
40
|
+
s.host = 'example.com';
|
|
41
|
+
s.realname = 'Alice';
|
|
42
|
+
s.registration = 'registered';
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const L = (text: string): RawLine => ({ text });
|
|
47
|
+
|
|
48
|
+
describe('whowasReducer — single nick with history', () => {
|
|
49
|
+
it('emits 314 RPL_WHOWASUSER then 369 RPL_ENDOFWHOWAS for a recorded nick', () => {
|
|
50
|
+
const requester = makeConn();
|
|
51
|
+
const clock = new FakeClock(10_000);
|
|
52
|
+
const ctx = makeCtx(requester, clock);
|
|
53
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
54
|
+
history.record({
|
|
55
|
+
nick: 'bob',
|
|
56
|
+
connId: 'c2',
|
|
57
|
+
username: 'bob',
|
|
58
|
+
hostname: 'target.example.net',
|
|
59
|
+
realname: 'Bob',
|
|
60
|
+
signoffTime: 5_000,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
|
|
64
|
+
|
|
65
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
66
|
+
Effect.send('c1', [L(':irc.example.com 314 alice bob bob target.example.net * :Bob')]),
|
|
67
|
+
Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('updates the requester lastSeen to ctx.clock.now()', () => {
|
|
72
|
+
const requester = makeConn();
|
|
73
|
+
const clock = new FakeClock(7_700);
|
|
74
|
+
const ctx = makeCtx(requester, clock);
|
|
75
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
76
|
+
history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
|
|
77
|
+
|
|
78
|
+
whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
|
|
79
|
+
|
|
80
|
+
expect(requester.lastSeen).toBe(7_700);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('uses ? fallbacks when the entry has no user/host and nick as realname', () => {
|
|
84
|
+
const requester = makeConn();
|
|
85
|
+
const clock = new FakeClock(10_000);
|
|
86
|
+
const ctx = makeCtx(requester, clock);
|
|
87
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
88
|
+
history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
|
|
89
|
+
|
|
90
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
|
|
91
|
+
|
|
92
|
+
const send = out.effects[0];
|
|
93
|
+
if (send?.tag === 'Send') {
|
|
94
|
+
expect(send.lines[0]?.text).toBe(':irc.example.com 314 alice bob ? ? * :bob');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('whowasReducer — no match', () => {
|
|
100
|
+
it('emits 406 ERR_WASNOSUCHNICK then 369 for an unknown nick', () => {
|
|
101
|
+
const requester = makeConn();
|
|
102
|
+
const ctx = makeCtx(requester);
|
|
103
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
|
|
104
|
+
|
|
105
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['ghost'] }, ctx);
|
|
106
|
+
|
|
107
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
108
|
+
Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
|
|
109
|
+
Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('whowasReducer — multiple comma-separated nicks', () => {
|
|
115
|
+
it('emits a 314+369 (or 406+369) block per queried nick in order', () => {
|
|
116
|
+
const requester = makeConn();
|
|
117
|
+
const clock = new FakeClock(10_000);
|
|
118
|
+
const ctx = makeCtx(requester, clock);
|
|
119
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
120
|
+
history.record({
|
|
121
|
+
nick: 'bob',
|
|
122
|
+
connId: 'c2',
|
|
123
|
+
username: 'bob',
|
|
124
|
+
hostname: 'h',
|
|
125
|
+
realname: 'Bob',
|
|
126
|
+
signoffTime: 1_000,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob,ghost'] }, ctx);
|
|
130
|
+
|
|
131
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
132
|
+
Effect.send('c1', [L(':irc.example.com 314 alice bob bob h * :Bob')]),
|
|
133
|
+
Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
|
|
134
|
+
Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
|
|
135
|
+
Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('whowasReducer — count cap', () => {
|
|
141
|
+
it('caps the number of 314 lines at the requested count', () => {
|
|
142
|
+
const requester = makeConn();
|
|
143
|
+
const clock = new FakeClock(10_000);
|
|
144
|
+
const ctx = makeCtx(requester, clock);
|
|
145
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
146
|
+
history.record({
|
|
147
|
+
nick: 'bob',
|
|
148
|
+
connId: 'c1',
|
|
149
|
+
username: 'u1',
|
|
150
|
+
hostname: 'h',
|
|
151
|
+
realname: 'R',
|
|
152
|
+
signoffTime: 1_000,
|
|
153
|
+
});
|
|
154
|
+
clock.advance(100);
|
|
155
|
+
history.record({
|
|
156
|
+
nick: 'bob',
|
|
157
|
+
connId: 'c2',
|
|
158
|
+
username: 'u2',
|
|
159
|
+
hostname: 'h',
|
|
160
|
+
realname: 'R',
|
|
161
|
+
signoffTime: 1_100,
|
|
162
|
+
});
|
|
163
|
+
clock.advance(100);
|
|
164
|
+
history.record({
|
|
165
|
+
nick: 'bob',
|
|
166
|
+
connId: 'c3',
|
|
167
|
+
username: 'u3',
|
|
168
|
+
hostname: 'h',
|
|
169
|
+
realname: 'R',
|
|
170
|
+
signoffTime: 1_200,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '1'] }, ctx);
|
|
174
|
+
|
|
175
|
+
// Only the most-recent entry (c3) is emitted, then the single 369.
|
|
176
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
177
|
+
Effect.send('c1', [L(':irc.example.com 314 alice bob u3 h * :R')]),
|
|
178
|
+
Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('emits all matches when count exceeds the available history', () => {
|
|
183
|
+
const requester = makeConn();
|
|
184
|
+
const clock = new FakeClock(10_000);
|
|
185
|
+
const ctx = makeCtx(requester, clock);
|
|
186
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
187
|
+
history.record({
|
|
188
|
+
nick: 'bob',
|
|
189
|
+
connId: 'c1',
|
|
190
|
+
username: 'u1',
|
|
191
|
+
hostname: 'h',
|
|
192
|
+
realname: 'R',
|
|
193
|
+
signoffTime: 1_000,
|
|
194
|
+
});
|
|
195
|
+
history.record({
|
|
196
|
+
nick: 'bob',
|
|
197
|
+
connId: 'c2',
|
|
198
|
+
username: 'u2',
|
|
199
|
+
hostname: 'h',
|
|
200
|
+
realname: 'R',
|
|
201
|
+
signoffTime: 1_100,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '50'] }, ctx);
|
|
205
|
+
|
|
206
|
+
const lines314 = out.effects.filter((e) => {
|
|
207
|
+
if (e.tag !== 'Send') return false;
|
|
208
|
+
return e.lines.some((l) => l.text.includes(' 314 '));
|
|
209
|
+
});
|
|
210
|
+
expect(lines314).toHaveLength(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('treats a non-positive or malformed count as unlimited', () => {
|
|
214
|
+
const requester = makeConn();
|
|
215
|
+
const clock = new FakeClock(10_000);
|
|
216
|
+
const ctx = makeCtx(requester, clock);
|
|
217
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
218
|
+
history.record({
|
|
219
|
+
nick: 'bob',
|
|
220
|
+
connId: 'c1',
|
|
221
|
+
username: 'u1',
|
|
222
|
+
hostname: 'h',
|
|
223
|
+
realname: 'R',
|
|
224
|
+
signoffTime: 1_000,
|
|
225
|
+
});
|
|
226
|
+
history.record({
|
|
227
|
+
nick: 'bob',
|
|
228
|
+
connId: 'c2',
|
|
229
|
+
username: 'u2',
|
|
230
|
+
hostname: 'h',
|
|
231
|
+
realname: 'R',
|
|
232
|
+
signoffTime: 1_100,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '0'] }, ctx);
|
|
236
|
+
|
|
237
|
+
const count314 = out.effects.filter((e) => {
|
|
238
|
+
if (e.tag !== 'Send') return false;
|
|
239
|
+
return e.lines.some((l) => l.text.includes(' 314 '));
|
|
240
|
+
});
|
|
241
|
+
expect(count314).toHaveLength(2);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('whowasReducer — no arg / 431', () => {
|
|
246
|
+
it('emits 431 ERR_NONICKNAMEGIVEN when no nick argument is supplied', () => {
|
|
247
|
+
const requester = makeConn();
|
|
248
|
+
const ctx = makeCtx(requester);
|
|
249
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
|
|
250
|
+
|
|
251
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: [] }, ctx);
|
|
252
|
+
|
|
253
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
254
|
+
Effect.send('c1', [L(':irc.example.com 431 alice :No nickname given')]),
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('whowasReducer — case-insensitive lookup', () => {
|
|
260
|
+
it('matches the recorded nick regardless of case', () => {
|
|
261
|
+
const requester = makeConn();
|
|
262
|
+
const clock = new FakeClock(10_000);
|
|
263
|
+
const ctx = makeCtx(requester, clock);
|
|
264
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
265
|
+
history.record({
|
|
266
|
+
nick: 'Bob',
|
|
267
|
+
connId: 'c2',
|
|
268
|
+
username: 'bob',
|
|
269
|
+
hostname: 'h',
|
|
270
|
+
realname: 'Bob',
|
|
271
|
+
signoffTime: 1_000,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['BOB'] }, ctx);
|
|
275
|
+
|
|
276
|
+
expect(out.effects[0]?.tag).toBe('Send');
|
|
277
|
+
const send = out.effects[0];
|
|
278
|
+
if (send?.tag === 'Send') {
|
|
279
|
+
expect(send.lines[0]?.text).toContain(' 314 alice Bob ');
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('whowasReducer — no store bound', () => {
|
|
285
|
+
it('treats undefined history as no matches (406 + 369, no crash)', () => {
|
|
286
|
+
const requester = makeConn();
|
|
287
|
+
const ctx = makeCtx(requester);
|
|
288
|
+
|
|
289
|
+
const out = whowasReducer(undefined, { command: 'WHOWAS', params: ['ghost'] }, ctx);
|
|
290
|
+
|
|
291
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
292
|
+
Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
|
|
293
|
+
Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('whowasReducer — requester without nick', () => {
|
|
299
|
+
it('uses * as the requester nick in replies', () => {
|
|
300
|
+
const requester = createConnection({ id: 'c1', connectedSince: 0 });
|
|
301
|
+
const ctx = makeCtx(requester);
|
|
302
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
|
|
303
|
+
history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
|
|
304
|
+
|
|
305
|
+
const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
|
|
306
|
+
|
|
307
|
+
const end = out.effects.at(-1);
|
|
308
|
+
if (end?.tag === 'Send') {
|
|
309
|
+
expect(end.lines[0]?.text).toBe(':irc.example.com 369 * bob :End of WHOWAS');
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_CREATED_TEXT,
|
|
4
|
+
DEFAULT_SERVER_CONFIG,
|
|
5
|
+
DEFAULT_SERVER_VERSION,
|
|
6
|
+
ServerConfigSchema,
|
|
7
|
+
formatCreatedAt,
|
|
8
|
+
parseServerConfig,
|
|
9
|
+
resolveServerVersion,
|
|
10
|
+
} from '../src/config';
|
|
3
11
|
|
|
4
12
|
// ============================================================================
|
|
5
13
|
// ServerConfigSchema — valid inputs
|
|
@@ -213,6 +221,92 @@ describe('ServerConfigSchema — maxClients', () => {
|
|
|
213
221
|
});
|
|
214
222
|
});
|
|
215
223
|
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// ServerConfigSchema — serverVersion / createdAt
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
describe('ServerConfigSchema — serverVersion', () => {
|
|
229
|
+
it('accepts an explicit non-empty version', () => {
|
|
230
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: '9.9.9' });
|
|
231
|
+
expect(cfg.serverVersion).toBe('9.9.9');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('defaults to DEFAULT_SERVER_VERSION when omitted', () => {
|
|
235
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
|
|
236
|
+
expect(cfg.serverVersion).toBe(DEFAULT_SERVER_VERSION);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('rejects an empty version string', () => {
|
|
240
|
+
expect(() =>
|
|
241
|
+
parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: '' }),
|
|
242
|
+
).toThrowError(/serverVersion/u);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects a non-string version', () => {
|
|
246
|
+
expect(() =>
|
|
247
|
+
parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: 42 }),
|
|
248
|
+
).toThrowError(/serverVersion/u);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('ServerConfigSchema — createdAt', () => {
|
|
253
|
+
it('accepts a non-empty string verbatim', () => {
|
|
254
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n', createdAt: '2024-06-01' });
|
|
255
|
+
expect(cfg.createdAt).toBe('2024-06-01');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('accepts an epoch-ms number', () => {
|
|
259
|
+
const cfg = parseServerConfig({
|
|
260
|
+
serverName: 's',
|
|
261
|
+
networkName: 'n',
|
|
262
|
+
createdAt: 1_700_000_000_000,
|
|
263
|
+
});
|
|
264
|
+
expect(cfg.createdAt).toBe(1_700_000_000_000);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('defaults to DEFAULT_CREATED_TEXT when omitted', () => {
|
|
268
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
|
|
269
|
+
expect(cfg.createdAt).toBe(DEFAULT_CREATED_TEXT);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('rejects an empty string', () => {
|
|
273
|
+
expect(() =>
|
|
274
|
+
parseServerConfig({ serverName: 's', networkName: 'n', createdAt: '' }),
|
|
275
|
+
).toThrowError(/createdAt/u);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('rejects a non-string/non-number value', () => {
|
|
279
|
+
expect(() =>
|
|
280
|
+
parseServerConfig({ serverName: 's', networkName: 'n', createdAt: true }),
|
|
281
|
+
).toThrowError(/createdAt/u);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// resolveServerVersion / formatCreatedAt helpers
|
|
287
|
+
// ============================================================================
|
|
288
|
+
|
|
289
|
+
describe('resolveServerVersion', () => {
|
|
290
|
+
it('returns the configured value when present', () => {
|
|
291
|
+
expect(resolveServerVersion({ serverVersion: '1.2.3' })).toBe('1.2.3');
|
|
292
|
+
});
|
|
293
|
+
it('falls back to DEFAULT_SERVER_VERSION when undefined', () => {
|
|
294
|
+
expect(resolveServerVersion({})).toBe(DEFAULT_SERVER_VERSION);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('formatCreatedAt', () => {
|
|
299
|
+
it('returns a string verbatim', () => {
|
|
300
|
+
expect(formatCreatedAt('2024-06-01')).toBe('2024-06-01');
|
|
301
|
+
});
|
|
302
|
+
it('formats an epoch-ms number as a UTC timestamp', () => {
|
|
303
|
+
expect(formatCreatedAt(1_700_000_000_000)).toBe(new Date(1_700_000_000_000).toUTCString());
|
|
304
|
+
});
|
|
305
|
+
it('falls back to DEFAULT_CREATED_TEXT when undefined', () => {
|
|
306
|
+
expect(formatCreatedAt(undefined)).toBe(DEFAULT_CREATED_TEXT);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
216
310
|
// ============================================================================
|
|
217
311
|
// ServerConfigSchema — operCreds
|
|
218
312
|
// ============================================================================
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type HashedAccountCredential,
|
|
4
|
+
HashedAccountStore,
|
|
5
|
+
hashAccountCredential,
|
|
6
|
+
verifyHashedPassword,
|
|
7
|
+
} from '../src/credential-hashing';
|
|
8
|
+
import type { AccountStore, SaslPayload } from '../src/ports';
|
|
9
|
+
|
|
10
|
+
describe('hashAccountCredential', () => {
|
|
11
|
+
it('produces an entry keyed by the username with scrypt', () => {
|
|
12
|
+
const entry = hashAccountCredential('alice', 'secret');
|
|
13
|
+
expect(entry.account).toBe('alice');
|
|
14
|
+
expect(entry.algorithm).toBe('scrypt');
|
|
15
|
+
expect(entry.salt.length).toBeGreaterThan(0);
|
|
16
|
+
expect(entry.hash.length).toBeGreaterThan(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('never stores the plaintext password in any field', () => {
|
|
20
|
+
const entry = hashAccountCredential('alice', 'hunter2-secret');
|
|
21
|
+
expect(entry.account).not.toContain('hunter2');
|
|
22
|
+
expect(entry.salt).not.toContain('hunter2');
|
|
23
|
+
expect(entry.hash).not.toContain('hunter2');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('produces different hashes for the same password under different salts', () => {
|
|
27
|
+
const a = hashAccountCredential('alice', 'secret', { salt: new Uint8Array(16).fill(0x61) });
|
|
28
|
+
const b = hashAccountCredential('alice', 'secret', { salt: new Uint8Array(16).fill(0x62) });
|
|
29
|
+
expect(a.hash).not.toBe(b.hash);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is deterministic for a fixed salt', () => {
|
|
33
|
+
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
|
34
|
+
const a = hashAccountCredential('alice', 'secret', { salt });
|
|
35
|
+
const b = hashAccountCredential('alice', 'secret', { salt });
|
|
36
|
+
expect(a.hash).toBe(b.hash);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('honours a custom key length', () => {
|
|
40
|
+
const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
|
|
41
|
+
// base64 of 32 bytes → ~44 chars; decode to verify length.
|
|
42
|
+
const decoded = Buffer.from(entry.hash, 'base64');
|
|
43
|
+
expect(decoded.length).toBe(32);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('generates a fresh random salt when none is supplied', () => {
|
|
47
|
+
const a = hashAccountCredential('alice', 'secret');
|
|
48
|
+
const b = hashAccountCredential('alice', 'secret');
|
|
49
|
+
expect(a.salt).not.toBe(b.salt);
|
|
50
|
+
expect(a.hash).not.toBe(b.hash);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('verifyHashedPassword', () => {
|
|
55
|
+
it('round-trips a hashed credential: correct password verifies', () => {
|
|
56
|
+
const entry = hashAccountCredential('alice', 's3cret');
|
|
57
|
+
expect(verifyHashedPassword('s3cret', entry)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a wrong password', () => {
|
|
61
|
+
const entry = hashAccountCredential('alice', 's3cret');
|
|
62
|
+
expect(verifyHashedPassword('wrong', entry)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects a non-scrypt algorithm entry', () => {
|
|
66
|
+
const entry: HashedAccountCredential = {
|
|
67
|
+
account: 'alice',
|
|
68
|
+
algorithm: 'argon2' as 'scrypt',
|
|
69
|
+
salt: 'aaaa',
|
|
70
|
+
hash: 'bbbb',
|
|
71
|
+
};
|
|
72
|
+
expect(verifyHashedPassword('s3cret', entry)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a malformed base64 salt without throwing', () => {
|
|
76
|
+
const entry: HashedAccountCredential = {
|
|
77
|
+
account: 'alice',
|
|
78
|
+
algorithm: 'scrypt',
|
|
79
|
+
salt: 'not!!valid*b64',
|
|
80
|
+
hash: 'bbbb',
|
|
81
|
+
};
|
|
82
|
+
expect(verifyHashedPassword('s3cret', entry)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects an entry whose decoded hash is empty', () => {
|
|
86
|
+
const entry: HashedAccountCredential = {
|
|
87
|
+
account: 'alice',
|
|
88
|
+
algorithm: 'scrypt',
|
|
89
|
+
salt: 'AAAA',
|
|
90
|
+
hash: '',
|
|
91
|
+
};
|
|
92
|
+
expect(verifyHashedPassword('s3cret', entry)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('HashedAccountStore', () => {
|
|
97
|
+
function plain(username: string, password: string): SaslPayload {
|
|
98
|
+
return { kind: 'PLAIN', username, password };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('implements the AccountStore port', () => {
|
|
102
|
+
const store: AccountStore = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
103
|
+
expect(store).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
107
|
+
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
108
|
+
expect(store.verify('PLAIN', plain('alice', 's3cret'))).toEqual({
|
|
109
|
+
ok: true,
|
|
110
|
+
account: 'alice',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects a wrong password', () => {
|
|
115
|
+
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
116
|
+
expect(store.verify('PLAIN', plain('alice', 'wrong')).ok).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects an unknown username', () => {
|
|
120
|
+
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
121
|
+
expect(store.verify('PLAIN', plain('bob', 's3cret')).ok).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects a non-PLAIN mechanism', () => {
|
|
125
|
+
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
126
|
+
expect(store.verify('EXTERNAL', plain('alice', 's3cret')).ok).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects a RAW payload', () => {
|
|
130
|
+
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
131
|
+
expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('handles multiple loaded accounts', () => {
|
|
135
|
+
const store = new HashedAccountStore([
|
|
136
|
+
hashAccountCredential('alice', 'a-secret'),
|
|
137
|
+
hashAccountCredential('bob', 'b-secret'),
|
|
138
|
+
]);
|
|
139
|
+
expect(store.verify('PLAIN', plain('alice', 'a-secret'))).toEqual({
|
|
140
|
+
ok: true,
|
|
141
|
+
account: 'alice',
|
|
142
|
+
});
|
|
143
|
+
expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual({
|
|
144
|
+
ok: true,
|
|
145
|
+
account: 'bob',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('fails every verify when constructed with no entries', () => {
|
|
150
|
+
const store = new HashedAccountStore([]);
|
|
151
|
+
expect(store.verify('PLAIN', plain('alice', 's3cret')).ok).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('reports the number of loaded entries via size', () => {
|
|
155
|
+
const store = new HashedAccountStore([
|
|
156
|
+
hashAccountCredential('alice', 'a'),
|
|
157
|
+
hashAccountCredential('bob', 'b'),
|
|
158
|
+
]);
|
|
159
|
+
expect(store.size).toBe(2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('round-trips an externally-constructed HashedAccountCredential', () => {
|
|
163
|
+
const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
|
|
164
|
+
const store = new HashedAccountStore([row]);
|
|
165
|
+
expect(store.verify('PLAIN', plain('carol', 'p@ss'))).toEqual({
|
|
166
|
+
ok: true,
|
|
167
|
+
account: 'carol',
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -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
|
+
});
|