serverless-ircd 0.2.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 +258 -18
- package/README.md +72 -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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- 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 +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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 +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- 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 +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- 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/cf-runtime.test.ts +91 -0
- 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.test.ts +40 -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 +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- 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/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -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 +84 -3
- 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 +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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 +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -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 +182 -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 +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.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 +78 -13
- 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 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { operReducer } from '../../src/commands/oper';
|
|
2
3
|
import { whoReducer } from '../../src/commands/who';
|
|
3
4
|
import { Effect } from '../../src/effects';
|
|
4
5
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
@@ -10,6 +11,10 @@ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
|
10
11
|
const serverConfig: ServerConfig = {
|
|
11
12
|
serverName: 'irc.example.com',
|
|
12
13
|
networkName: 'ExampleNet',
|
|
14
|
+
operCreds: [
|
|
15
|
+
{ user: 'alice', password: 'oppass' },
|
|
16
|
+
{ user: 'bob', password: 'oppass' },
|
|
17
|
+
],
|
|
13
18
|
maxChannelsPerUser: 30,
|
|
14
19
|
maxTargetsPerCommand: 10,
|
|
15
20
|
maxListEntries: 50,
|
|
@@ -19,6 +24,13 @@ const serverConfig: ServerConfig = {
|
|
|
19
24
|
quitMessage: 'Client Quit',
|
|
20
25
|
};
|
|
21
26
|
|
|
27
|
+
/** Grants oper to a connection via the real OPER command flow. */
|
|
28
|
+
function grantOper(conn: ConnectionState): void {
|
|
29
|
+
const ctx = makeCtx(conn);
|
|
30
|
+
const user = conn.nick ?? 'oper';
|
|
31
|
+
operReducer(conn, { command: 'OPER', params: [user, 'oppass'], tags: {} }, ctx);
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
|
|
23
35
|
return buildCtx({
|
|
24
36
|
serverConfig,
|
|
@@ -129,9 +141,11 @@ describe('whoReducer — channel target', () => {
|
|
|
129
141
|
addMember(chan, 'c1', 'alice');
|
|
130
142
|
const conn = makeConn();
|
|
131
143
|
const ctx = makeCtx(conn);
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
const alice = makeConnState('c1', 'alice');
|
|
145
|
+
// Grant oper via the real OPER command flow, not by poking state.
|
|
146
|
+
grantOper(alice);
|
|
147
|
+
expect(alice.userModes.oper).toBe(true);
|
|
148
|
+
const connStates = new Map<string, ConnectionState>([['c1', alice]]);
|
|
135
149
|
|
|
136
150
|
const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
|
|
137
151
|
|
|
@@ -333,9 +347,13 @@ describe('whoReducer — operators-only filter', () => {
|
|
|
333
347
|
addMember(chan, 'c2', 'bob');
|
|
334
348
|
const conn = makeConn();
|
|
335
349
|
const ctx = makeCtx(conn);
|
|
350
|
+
const bob = makeConnState('c2', 'bob');
|
|
351
|
+
// Grant oper via the real OPER command flow, not by poking state.
|
|
352
|
+
grantOper(bob);
|
|
353
|
+
expect(bob.userModes.oper).toBe(true);
|
|
336
354
|
const connStates = new Map<string, ConnectionState>([
|
|
337
355
|
['c1', makeConnState('c1', 'alice')],
|
|
338
|
-
['c2',
|
|
356
|
+
['c2', bob],
|
|
339
357
|
]);
|
|
340
358
|
|
|
341
359
|
const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo', 'o'] }, ctx);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { operReducer } from '../../src/commands/oper';
|
|
2
3
|
import { whoisReducer } from '../../src/commands/whois';
|
|
3
4
|
import { Effect } from '../../src/effects';
|
|
4
5
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
@@ -10,6 +11,7 @@ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
|
10
11
|
const serverConfig: ServerConfig = {
|
|
11
12
|
serverName: 'irc.example.com',
|
|
12
13
|
networkName: 'ExampleNet',
|
|
14
|
+
operCreds: [{ user: 'bob', password: 'oppass' }],
|
|
13
15
|
maxChannelsPerUser: 30,
|
|
14
16
|
maxTargetsPerCommand: 10,
|
|
15
17
|
maxListEntries: 50,
|
|
@@ -150,13 +152,18 @@ describe('whoisReducer — happy path', () => {
|
|
|
150
152
|
it('emits 313 RPL_WHOISOPERATOR when the target is an IRC operator', () => {
|
|
151
153
|
const requester = makeConn();
|
|
152
154
|
const ctx = makeCtx(requester);
|
|
153
|
-
|
|
155
|
+
// Grant oper via the real OPER command flow rather than poking state directly.
|
|
156
|
+
const target = makeTarget('c2', 'bob');
|
|
157
|
+
const targetCtx = makeCtx(target);
|
|
158
|
+
operReducer(target, { command: 'OPER', params: ['bob', 'oppass'], tags: {} }, targetCtx);
|
|
159
|
+
expect(target.userModes.oper).toBe(true);
|
|
154
160
|
|
|
155
161
|
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
156
162
|
|
|
157
163
|
const send = out.effects[0];
|
|
158
164
|
if (send?.tag === 'Send') {
|
|
159
165
|
expect(send.lines.some((l) => l.text.includes(' 313 '))).toBe(true);
|
|
166
|
+
expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);
|
|
160
167
|
}
|
|
161
168
|
});
|
|
162
169
|
});
|
|
@@ -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
|
+
});
|