serverless-ircd 0.1.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 +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- package/turbo.json +29 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { joinReducer } from '../../src/commands/join';
|
|
3
|
+
import { namesReducer } from '../../src/commands/names';
|
|
4
|
+
import { Effect } from '../../src/effects';
|
|
5
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
6
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
7
|
+
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
8
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
9
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
10
|
+
|
|
11
|
+
const serverConfig: ServerConfig = {
|
|
12
|
+
serverName: 'irc.example.com',
|
|
13
|
+
networkName: 'ExampleNet',
|
|
14
|
+
maxChannelsPerUser: 30,
|
|
15
|
+
maxTargetsPerCommand: 10,
|
|
16
|
+
maxListEntries: 50,
|
|
17
|
+
nickLen: 30,
|
|
18
|
+
channelLen: 50,
|
|
19
|
+
topicLen: 390,
|
|
20
|
+
quitMessage: 'Client Quit',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function makeCtx(conn: ConnectionState, ids = new SequentialIdFactory()): Ctx {
|
|
24
|
+
return buildCtx({
|
|
25
|
+
serverConfig,
|
|
26
|
+
clock: new FakeClock(1_000),
|
|
27
|
+
ids,
|
|
28
|
+
motd: EmptyMotdProvider,
|
|
29
|
+
connection: conn,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
34
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
35
|
+
s.nick = nick;
|
|
36
|
+
s.user = nick;
|
|
37
|
+
s.host = 'example.com';
|
|
38
|
+
s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
|
|
39
|
+
s.registration = 'registered';
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeChan(name = '#foo'): ChannelState {
|
|
44
|
+
return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addMember(chan: ChannelState, connId: string, nick: string): void {
|
|
48
|
+
chan.members.set(connId, { conn: connId, nick, op: false, voice: false });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const L = (text: string): RawLine => ({ text });
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// namesReducer — IRCv3 batch wrapping
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('namesReducer — IRCv3 batch', () => {
|
|
58
|
+
it('wraps the 353 + 366 reply in a BATCH frame when the requester has the batch cap', () => {
|
|
59
|
+
const chan = makeChan('#foo');
|
|
60
|
+
addMember(chan, 'c1', 'alice');
|
|
61
|
+
const conn = makeConn();
|
|
62
|
+
conn.caps.add('batch');
|
|
63
|
+
const ctx = makeCtx(conn);
|
|
64
|
+
|
|
65
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
66
|
+
|
|
67
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
68
|
+
Effect.send('c1', [
|
|
69
|
+
L('BATCH +batch-0 names'),
|
|
70
|
+
L(':irc.example.com 353 alice = #foo :alice'),
|
|
71
|
+
L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
|
|
72
|
+
L('BATCH -batch-0'),
|
|
73
|
+
]),
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('leaves the reply unwrapped when the requester lacks the batch cap', () => {
|
|
78
|
+
const chan = makeChan('#foo');
|
|
79
|
+
addMember(chan, 'c1', 'alice');
|
|
80
|
+
const conn = makeConn();
|
|
81
|
+
const ctx = makeCtx(conn);
|
|
82
|
+
|
|
83
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
84
|
+
|
|
85
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
86
|
+
Effect.send('c1', [
|
|
87
|
+
L(':irc.example.com 353 alice = #foo :alice'),
|
|
88
|
+
L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
|
|
89
|
+
]),
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does NOT wrap the 403 error path in a batch', () => {
|
|
94
|
+
const chan = makeChan('#foo');
|
|
95
|
+
addMember(chan, 'c1', 'alice');
|
|
96
|
+
const conn = makeConn();
|
|
97
|
+
conn.caps.add('batch');
|
|
98
|
+
const ctx = makeCtx(conn);
|
|
99
|
+
|
|
100
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['badname'], tags: {} }, ctx);
|
|
101
|
+
|
|
102
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
103
|
+
Effect.send('c1', [L(':irc.example.com 403 alice badname :No such channel')]),
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// joinReducer — IRCv3 batch wrapping of the joiner's NAMES reply
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
describe('joinReducer — IRCv3 batch', () => {
|
|
113
|
+
it('wraps the joiner-only 353 + 366 reply in a BATCH when the joiner has the batch cap', () => {
|
|
114
|
+
const chan = makeChan('#foo');
|
|
115
|
+
addMember(chan, 'c2', 'bob');
|
|
116
|
+
const conn = makeConn();
|
|
117
|
+
conn.caps.add('batch');
|
|
118
|
+
const ctx = makeCtx(conn);
|
|
119
|
+
|
|
120
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
121
|
+
|
|
122
|
+
// The Send effect to the joiner (c1) carries the wrapped NAMES reply.
|
|
123
|
+
// The Broadcast JOIN effect goes to the channel and is unaffected.
|
|
124
|
+
const sendToJoiner = out.effects.find(
|
|
125
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> =>
|
|
126
|
+
e.tag === 'Send' && e.to === 'c1' && e.lines.length === 4,
|
|
127
|
+
);
|
|
128
|
+
expect(sendToJoiner).toBeDefined();
|
|
129
|
+
expect(sendToJoiner?.lines[0]?.text.startsWith('BATCH +batch-')).toBe(true);
|
|
130
|
+
expect(sendToJoiner?.lines[0]?.text.endsWith(' join')).toBe(true);
|
|
131
|
+
expect(sendToJoiner?.lines[1]?.text).toBe(':irc.example.com 353 alice = #foo :bob alice');
|
|
132
|
+
expect(sendToJoiner?.lines[2]?.text).toBe(
|
|
133
|
+
':irc.example.com 366 alice #foo :End of /NAMES list.',
|
|
134
|
+
);
|
|
135
|
+
expect(sendToJoiner?.lines[3]?.text).toMatch(/^BATCH -batch-\d+$/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('leaves the joiner NAMES reply unwrapped without the batch cap', () => {
|
|
139
|
+
const chan = makeChan('#foo');
|
|
140
|
+
addMember(chan, 'c2', 'bob');
|
|
141
|
+
const conn = makeConn();
|
|
142
|
+
const ctx = makeCtx(conn);
|
|
143
|
+
|
|
144
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
145
|
+
|
|
146
|
+
const sendToJoiner = out.effects.find(
|
|
147
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> =>
|
|
148
|
+
e.tag === 'Send' && e.to === 'c1' && e.lines.length === 2,
|
|
149
|
+
);
|
|
150
|
+
expect(sendToJoiner).toBeDefined();
|
|
151
|
+
expect(sendToJoiner?.lines[0]?.text).toBe(':irc.example.com 353 alice = #foo :bob alice');
|
|
152
|
+
expect(sendToJoiner?.lines[1]?.text).toBe(
|
|
153
|
+
':irc.example.com 366 alice #foo :End of /NAMES list.',
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('still wraps the 353+366 in a batch even when the broadcast is cap-split', () => {
|
|
158
|
+
const chan = makeChan('#foo');
|
|
159
|
+
addMember(chan, 'c2', 'bob');
|
|
160
|
+
const conn = makeConn();
|
|
161
|
+
conn.caps.add('batch');
|
|
162
|
+
conn.caps.add('extended-join');
|
|
163
|
+
const ctx = makeCtx(conn);
|
|
164
|
+
|
|
165
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
166
|
+
|
|
167
|
+
// Broadcast (with extended-join cap-split) and Send-to-joiner (batched).
|
|
168
|
+
const broadcast = out.effects.find(
|
|
169
|
+
(e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
|
|
170
|
+
);
|
|
171
|
+
expect(broadcast?.cap).toBe('extended-join');
|
|
172
|
+
const send = out.effects.find(
|
|
173
|
+
(e): e is Extract<EffectType, { tag: 'Send' }> =>
|
|
174
|
+
e.tag === 'Send' && e.to === 'c1' && e.lines.length === 4,
|
|
175
|
+
);
|
|
176
|
+
expect(send?.lines[0]?.text.startsWith('BATCH +')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getLsString } from '../../src/caps/capabilities';
|
|
3
|
+
import { capReducer } from '../../src/commands/cap';
|
|
4
|
+
import { userReducer } from '../../src/commands/registration';
|
|
5
|
+
import { Effect } from '../../src/effects';
|
|
6
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
8
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
9
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
10
|
+
|
|
11
|
+
const serverConfig: ServerConfig = {
|
|
12
|
+
serverName: 'irc.example.com',
|
|
13
|
+
networkName: 'ExampleNet',
|
|
14
|
+
maxChannelsPerUser: 30,
|
|
15
|
+
maxTargetsPerCommand: 10,
|
|
16
|
+
maxListEntries: 50,
|
|
17
|
+
nickLen: 30,
|
|
18
|
+
channelLen: 50,
|
|
19
|
+
topicLen: 390,
|
|
20
|
+
quitMessage: 'Client Quit',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function makeCtx(state: ConnectionState, cfg: ServerConfig = serverConfig): Ctx {
|
|
24
|
+
return buildCtx({
|
|
25
|
+
serverConfig: cfg,
|
|
26
|
+
clock: new FakeClock(1_000),
|
|
27
|
+
ids: new SequentialIdFactory(),
|
|
28
|
+
motd: EmptyMotdProvider,
|
|
29
|
+
connection: state,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeState(): ConnectionState {
|
|
34
|
+
return createConnection({ id: 'c1', connectedSince: 0 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convenience state with nick+user set, still pre-welcome, not yet CAP-gated. */
|
|
38
|
+
function readyState(): ConnectionState {
|
|
39
|
+
const s = makeState();
|
|
40
|
+
s.nick = 'alice';
|
|
41
|
+
s.user = 'alice';
|
|
42
|
+
s.host = 'example.com';
|
|
43
|
+
s.realname = 'Alice';
|
|
44
|
+
s.registration = 'registering';
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const L = (text: string): RawLine => ({ text });
|
|
49
|
+
|
|
50
|
+
/** Expected CAP LS body (single-line form, bare list of caps). */
|
|
51
|
+
const EXPECTED_LS = getLsString();
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// capReducer — LS
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('capReducer — LS', () => {
|
|
58
|
+
it('responds with `CAP * LS :<caps>` for an unregistered client', () => {
|
|
59
|
+
const state = makeState();
|
|
60
|
+
const ctx = makeCtx(state);
|
|
61
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
62
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
63
|
+
Effect.send('c1', [L(`:irc.example.com CAP * LS :${EXPECTED_LS}`)]),
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses the connection nick as the target once registered', () => {
|
|
68
|
+
const state = readyState();
|
|
69
|
+
state.registration = 'registered';
|
|
70
|
+
const ctx = makeCtx(state);
|
|
71
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
72
|
+
const send = out.effects[0];
|
|
73
|
+
expect(send).toBeDefined();
|
|
74
|
+
if (send?.tag === 'Send') {
|
|
75
|
+
expect(send.lines[0]?.text).toContain('CAP alice LS :');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('accepts a client-advertised CAP LS version number', () => {
|
|
80
|
+
const state = makeState();
|
|
81
|
+
const ctx = makeCtx(state);
|
|
82
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS', '302'], tags: {} }, ctx);
|
|
83
|
+
const send = out.effects[0];
|
|
84
|
+
expect(send).toBeDefined();
|
|
85
|
+
if (send?.tag === 'Send') {
|
|
86
|
+
expect(send.lines[0]?.text).toContain('CAP * LS :');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('accepts a lowercase subcommand token', () => {
|
|
91
|
+
const state = makeState();
|
|
92
|
+
const ctx = makeCtx(state);
|
|
93
|
+
const out = capReducer(state, { command: 'CAP', params: ['ls'], tags: {} }, ctx);
|
|
94
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
95
|
+
Effect.send('c1', [L(`:irc.example.com CAP * LS :${EXPECTED_LS}`)]),
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('advertises safelist as a supported capability', () => {
|
|
100
|
+
const state = makeState();
|
|
101
|
+
const ctx = makeCtx(state);
|
|
102
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
103
|
+
const send = out.effects[0];
|
|
104
|
+
expect(send).toBeDefined();
|
|
105
|
+
if (send?.tag === 'Send') {
|
|
106
|
+
const body = send.lines[0]?.text ?? '';
|
|
107
|
+
expect(body).toContain('safelist');
|
|
108
|
+
// safelist has no value — must not appear in `name=value` form.
|
|
109
|
+
expect(body).not.toContain('safelist=');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
114
|
+
const state = makeState();
|
|
115
|
+
const ctx = makeCtx(state);
|
|
116
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
117
|
+
expect(out.state.capNegotiating).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('refreshes lastSeen', () => {
|
|
121
|
+
const state = makeState();
|
|
122
|
+
const clock = new FakeClock(5_000);
|
|
123
|
+
const ctx = buildCtx({
|
|
124
|
+
serverConfig,
|
|
125
|
+
clock,
|
|
126
|
+
ids: new SequentialIdFactory(),
|
|
127
|
+
motd: EmptyMotdProvider,
|
|
128
|
+
connection: state,
|
|
129
|
+
});
|
|
130
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
131
|
+
expect(out.state.lastSeen).toBe(5_000);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('emits multiple CAP LS lines with the multiline `*` marker when the payload overflows one line', () => {
|
|
135
|
+
const longName = `s${'x'.repeat(460)}`;
|
|
136
|
+
const cfg: ServerConfig = { ...serverConfig, serverName: longName };
|
|
137
|
+
const state = makeState();
|
|
138
|
+
const ctx = makeCtx(state, cfg);
|
|
139
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
140
|
+
const send = out.effects[0];
|
|
141
|
+
expect(send).toBeDefined();
|
|
142
|
+
if (send?.tag === 'Send') {
|
|
143
|
+
expect(send.lines.length).toBeGreaterThan(1);
|
|
144
|
+
for (let i = 0; i < send.lines.length - 1; i++) {
|
|
145
|
+
const line = send.lines[i]?.text ?? '';
|
|
146
|
+
expect(line).toContain(' LS * :');
|
|
147
|
+
}
|
|
148
|
+
const last = send.lines[send.lines.length - 1]?.text ?? '';
|
|
149
|
+
expect(last).toContain(' LS :');
|
|
150
|
+
expect(last).not.toContain(' LS * :');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// capReducer — LIST
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
describe('capReducer — LIST', () => {
|
|
160
|
+
it('returns an empty list when no caps are negotiated', () => {
|
|
161
|
+
const state = makeState();
|
|
162
|
+
const ctx = makeCtx(state);
|
|
163
|
+
const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);
|
|
164
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
165
|
+
Effect.send('c1', [L(':irc.example.com CAP * LIST :')]),
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns the currently negotiated caps for a registered client', () => {
|
|
170
|
+
const state = readyState();
|
|
171
|
+
state.registration = 'registered';
|
|
172
|
+
state.caps.add('server-time');
|
|
173
|
+
state.caps.add('multi-prefix');
|
|
174
|
+
const ctx = makeCtx(state);
|
|
175
|
+
const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);
|
|
176
|
+
const send = out.effects[0];
|
|
177
|
+
expect(send).toBeDefined();
|
|
178
|
+
if (send?.tag === 'Send') {
|
|
179
|
+
const body = send.lines[0]?.text ?? '';
|
|
180
|
+
expect(body).toContain('CAP alice LIST :');
|
|
181
|
+
expect(body).toContain('server-time');
|
|
182
|
+
expect(body).toContain('multi-prefix');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does not mutate caps or start CAP negotiation', () => {
|
|
187
|
+
const state = makeState();
|
|
188
|
+
state.caps.add('echo-message');
|
|
189
|
+
const ctx = makeCtx(state);
|
|
190
|
+
const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);
|
|
191
|
+
expect(out.state.caps).toEqual(new Set(['echo-message']));
|
|
192
|
+
expect(out.state.capNegotiating).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('emits multiple CAP LIST lines with the `*` marker when negotiated caps overflow one line', () => {
|
|
196
|
+
const longName = `s${'x'.repeat(460)}`;
|
|
197
|
+
const cfg: ServerConfig = { ...serverConfig, serverName: longName };
|
|
198
|
+
const state = makeState();
|
|
199
|
+
state.caps.add('server-time');
|
|
200
|
+
state.caps.add('multi-prefix');
|
|
201
|
+
state.caps.add('echo-message');
|
|
202
|
+
const ctx = makeCtx(state, cfg);
|
|
203
|
+
const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);
|
|
204
|
+
const send = out.effects[0];
|
|
205
|
+
expect(send).toBeDefined();
|
|
206
|
+
if (send?.tag === 'Send') {
|
|
207
|
+
expect(send.lines.length).toBeGreaterThan(1);
|
|
208
|
+
for (let i = 0; i < send.lines.length - 1; i++) {
|
|
209
|
+
expect(send.lines[i]?.text).toContain(' LIST * :');
|
|
210
|
+
}
|
|
211
|
+
expect(send.lines[send.lines.length - 1]?.text).toContain(' LIST :');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ============================================================================
|
|
217
|
+
// capReducer — REQ
|
|
218
|
+
// ============================================================================
|
|
219
|
+
|
|
220
|
+
describe('capReducer — REQ', () => {
|
|
221
|
+
it('ACKs a single known cap and records it on the connection', () => {
|
|
222
|
+
const state = readyState();
|
|
223
|
+
const ctx = makeCtx(state);
|
|
224
|
+
const out = capReducer(
|
|
225
|
+
state,
|
|
226
|
+
{ command: 'CAP', params: ['REQ', 'server-time'], tags: {} },
|
|
227
|
+
ctx,
|
|
228
|
+
);
|
|
229
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
230
|
+
Effect.send('c1', [L(':irc.example.com CAP alice ACK :server-time')]),
|
|
231
|
+
]);
|
|
232
|
+
expect(out.state.caps.has('server-time')).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('ACKs the safelist cap and records it on the connection', () => {
|
|
236
|
+
const state = readyState();
|
|
237
|
+
const ctx = makeCtx(state);
|
|
238
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'safelist'], tags: {} }, ctx);
|
|
239
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
240
|
+
Effect.send('c1', [L(':irc.example.com CAP alice ACK :safelist')]),
|
|
241
|
+
]);
|
|
242
|
+
expect(out.state.caps.has('safelist')).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('NAKs a single unknown cap', () => {
|
|
246
|
+
const state = readyState();
|
|
247
|
+
const ctx = makeCtx(state);
|
|
248
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'nope'], tags: {} }, ctx);
|
|
249
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
250
|
+
Effect.send('c1', [L(':irc.example.com CAP alice NAK :nope')]),
|
|
251
|
+
]);
|
|
252
|
+
expect(out.state.caps.has('nope')).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('ACKs multiple known caps requested in one REQ', () => {
|
|
256
|
+
const state = readyState();
|
|
257
|
+
const ctx = makeCtx(state);
|
|
258
|
+
const out = capReducer(
|
|
259
|
+
state,
|
|
260
|
+
{ command: 'CAP', params: ['REQ', 'server-time multi-prefix'], tags: {} },
|
|
261
|
+
ctx,
|
|
262
|
+
);
|
|
263
|
+
const send = out.effects[0];
|
|
264
|
+
expect(send).toBeDefined();
|
|
265
|
+
if (send?.tag === 'Send') {
|
|
266
|
+
const body = send.lines[0]?.text ?? '';
|
|
267
|
+
expect(body).toContain('ACK');
|
|
268
|
+
expect(body).toContain('server-time');
|
|
269
|
+
expect(body).toContain('multi-prefix');
|
|
270
|
+
}
|
|
271
|
+
expect(out.state.caps.has('server-time')).toBe(true);
|
|
272
|
+
expect(out.state.caps.has('multi-prefix')).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('NAKs the entire REQ when any requested cap is unknown (all-or-nothing)', () => {
|
|
276
|
+
const state = readyState();
|
|
277
|
+
const ctx = makeCtx(state);
|
|
278
|
+
const out = capReducer(
|
|
279
|
+
state,
|
|
280
|
+
{ command: 'CAP', params: ['REQ', 'server-time nope'], tags: {} },
|
|
281
|
+
ctx,
|
|
282
|
+
);
|
|
283
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
284
|
+
Effect.send('c1', [L(':irc.example.com CAP alice NAK :server-time nope')]),
|
|
285
|
+
]);
|
|
286
|
+
expect(out.state.caps.has('server-time')).toBe(false);
|
|
287
|
+
expect(out.state.caps.has('nope')).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('echoes the disable marker (`-`) in the ACK and removes the cap', () => {
|
|
291
|
+
const state = readyState();
|
|
292
|
+
state.caps.add('server-time');
|
|
293
|
+
const ctx = makeCtx(state);
|
|
294
|
+
const out = capReducer(
|
|
295
|
+
state,
|
|
296
|
+
{ command: 'CAP', params: ['REQ', '-server-time'], tags: {} },
|
|
297
|
+
ctx,
|
|
298
|
+
);
|
|
299
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
300
|
+
Effect.send('c1', [L(':irc.example.com CAP alice ACK :-server-time')]),
|
|
301
|
+
]);
|
|
302
|
+
expect(out.state.caps.has('server-time')).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
306
|
+
const state = readyState();
|
|
307
|
+
const ctx = makeCtx(state);
|
|
308
|
+
capReducer(state, { command: 'CAP', params: ['REQ', 'server-time'], tags: {} }, ctx);
|
|
309
|
+
expect(state.capNegotiating).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('emits 461 when no caps parameter is supplied', () => {
|
|
313
|
+
const state = readyState();
|
|
314
|
+
const ctx = makeCtx(state);
|
|
315
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ'], tags: {} }, ctx);
|
|
316
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
317
|
+
Effect.send('c1', [L(':irc.example.com 461 alice CAP :Not enough parameters')]),
|
|
318
|
+
]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('emits 461 when the caps parameter is an empty trailing string', () => {
|
|
322
|
+
const state = readyState();
|
|
323
|
+
const ctx = makeCtx(state);
|
|
324
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', ''], tags: {} }, ctx);
|
|
325
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
326
|
+
Effect.send('c1', [L(':irc.example.com 461 alice CAP :Not enough parameters')]),
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('targets `*` in ACK when the client has no nick yet', () => {
|
|
331
|
+
const state = makeState();
|
|
332
|
+
const ctx = makeCtx(state);
|
|
333
|
+
const out = capReducer(
|
|
334
|
+
state,
|
|
335
|
+
{ command: 'CAP', params: ['REQ', 'server-time'], tags: {} },
|
|
336
|
+
ctx,
|
|
337
|
+
);
|
|
338
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
339
|
+
Effect.send('c1', [L(':irc.example.com CAP * ACK :server-time')]),
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('targets `*` in the 461 reply when the client has no nick yet', () => {
|
|
344
|
+
const state = makeState();
|
|
345
|
+
const ctx = makeCtx(state);
|
|
346
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ'], tags: {} }, ctx);
|
|
347
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
348
|
+
Effect.send('c1', [L(':irc.example.com 461 * CAP :Not enough parameters')]),
|
|
349
|
+
]);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// capReducer — END
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
describe('capReducer — END', () => {
|
|
358
|
+
it('triggers the welcome sequence when registration is otherwise complete', () => {
|
|
359
|
+
const state = readyState();
|
|
360
|
+
state.capNegotiating = true;
|
|
361
|
+
const ctx = makeCtx(state);
|
|
362
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
363
|
+
expect(out.state.registration).toBe('registered');
|
|
364
|
+
expect(out.state.capNegotiating).toBe(false);
|
|
365
|
+
const welcome = out.effects[0];
|
|
366
|
+
expect(welcome).toBeDefined();
|
|
367
|
+
if (welcome?.tag === 'Send') {
|
|
368
|
+
expect(welcome.lines[0]?.text).toContain('001 alice');
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('does not emit a welcome when nick is not yet set', () => {
|
|
373
|
+
const state = makeState();
|
|
374
|
+
state.user = 'alice';
|
|
375
|
+
state.realname = 'Alice';
|
|
376
|
+
state.capNegotiating = true;
|
|
377
|
+
const ctx = makeCtx(state);
|
|
378
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
379
|
+
expect(out.state.registration).not.toBe('registered');
|
|
380
|
+
expect(out.effects).toEqual([]);
|
|
381
|
+
expect(out.state.capNegotiating).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('does not emit a welcome when user is not yet set', () => {
|
|
385
|
+
const state = makeState();
|
|
386
|
+
state.nick = 'alice';
|
|
387
|
+
state.capNegotiating = true;
|
|
388
|
+
const ctx = makeCtx(state);
|
|
389
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
390
|
+
expect(out.state.registration).not.toBe('registered');
|
|
391
|
+
expect(out.effects).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('clears capNegotiating even when registration is incomplete', () => {
|
|
395
|
+
const state = makeState();
|
|
396
|
+
state.capNegotiating = true;
|
|
397
|
+
const ctx = makeCtx(state);
|
|
398
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
399
|
+
expect(out.state.capNegotiating).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('is a no-op when the connection is already registered', () => {
|
|
403
|
+
const state = readyState();
|
|
404
|
+
state.registration = 'registered';
|
|
405
|
+
const ctx = makeCtx(state);
|
|
406
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
407
|
+
expect(out.effects).toEqual([]);
|
|
408
|
+
expect(out.state.registration).toBe('registered');
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// capReducer — deferred welcome integration with NICK/USER
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
describe('capReducer — deferred welcome', () => {
|
|
417
|
+
it('does not fire the welcome on USER while CAP negotiation is in progress', () => {
|
|
418
|
+
const state = makeState();
|
|
419
|
+
state.nick = 'alice';
|
|
420
|
+
state.capNegotiating = true;
|
|
421
|
+
state.registration = 'registering';
|
|
422
|
+
const ctx = makeCtx(state);
|
|
423
|
+
const out = userReducer(
|
|
424
|
+
state,
|
|
425
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
|
|
426
|
+
ctx,
|
|
427
|
+
);
|
|
428
|
+
expect(out.state.registration).toBe('registering');
|
|
429
|
+
expect(out.effects).toEqual([]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('fires the welcome on CAP END after USER was deferred', () => {
|
|
433
|
+
const state = makeState();
|
|
434
|
+
state.nick = 'alice';
|
|
435
|
+
state.capNegotiating = true;
|
|
436
|
+
state.registration = 'registering';
|
|
437
|
+
const ctx = makeCtx(state);
|
|
438
|
+
userReducer(state, { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} }, ctx);
|
|
439
|
+
const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);
|
|
440
|
+
expect(out.state.registration).toBe('registered');
|
|
441
|
+
const welcome = out.effects[0];
|
|
442
|
+
expect(welcome).toBeDefined();
|
|
443
|
+
if (welcome?.tag === 'Send') {
|
|
444
|
+
expect(welcome.lines[0]?.text).toContain('001 alice');
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ============================================================================
|
|
450
|
+
// capReducer — unknown / invalid subcommands
|
|
451
|
+
// ============================================================================
|
|
452
|
+
|
|
453
|
+
describe('capReducer — unknown subcommands', () => {
|
|
454
|
+
it('emits 410 ERR_INVALIDCAPCMD for an unknown subcommand', () => {
|
|
455
|
+
const state = makeState();
|
|
456
|
+
const ctx = makeCtx(state);
|
|
457
|
+
const out = capReducer(state, { command: 'CAP', params: ['BOGUS'], tags: {} }, ctx);
|
|
458
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
459
|
+
Effect.send('c1', [L(':irc.example.com 410 * BOGUS :Invalid CAP command')]),
|
|
460
|
+
]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('emits 410 for CAP NEW sent from a client (server-to-client only)', () => {
|
|
464
|
+
const state = makeState();
|
|
465
|
+
const ctx = makeCtx(state);
|
|
466
|
+
const out = capReducer(state, { command: 'CAP', params: ['NEW'], tags: {} }, ctx);
|
|
467
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
468
|
+
Effect.send('c1', [L(':irc.example.com 410 * NEW :Invalid CAP command')]),
|
|
469
|
+
]);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('emits 410 for CAP DEL sent from a client (server-to-client only)', () => {
|
|
473
|
+
const state = makeState();
|
|
474
|
+
const ctx = makeCtx(state);
|
|
475
|
+
const out = capReducer(state, { command: 'CAP', params: ['DEL'], tags: {} }, ctx);
|
|
476
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
477
|
+
Effect.send('c1', [L(':irc.example.com 410 * DEL :Invalid CAP command')]),
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('emits 410 when the subcommand is missing entirely', () => {
|
|
482
|
+
const state = makeState();
|
|
483
|
+
const ctx = makeCtx(state);
|
|
484
|
+
const out = capReducer(state, { command: 'CAP', params: [], tags: {} }, ctx);
|
|
485
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
486
|
+
Effect.send('c1', [L(':irc.example.com 410 * :Invalid CAP command')]),
|
|
487
|
+
]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('uses the connection nick in the 410 reply when registered', () => {
|
|
491
|
+
const state = readyState();
|
|
492
|
+
state.registration = 'registered';
|
|
493
|
+
const ctx = makeCtx(state);
|
|
494
|
+
const out = capReducer(state, { command: 'CAP', params: ['BOGUS'], tags: {} }, ctx);
|
|
495
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
496
|
+
Effect.send('c1', [L(':irc.example.com 410 alice BOGUS :Invalid CAP command')]),
|
|
497
|
+
]);
|
|
498
|
+
});
|
|
499
|
+
});
|