serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -139,6 +139,77 @@ describe('hostservReducer — dispatch', () => {
|
|
|
139
139
|
expect(sentTexts(out.effects).some((t) => t.includes('Unknown command'))).toBe(true);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it('hides oper-only commands from a non-oper on empty body', () => {
|
|
143
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
144
|
+
const conn = identifiedConn('bob');
|
|
145
|
+
const ctx = makeCtx(conn, services);
|
|
146
|
+
|
|
147
|
+
const out = hostservReducer(conn, privmsg(''), ctx);
|
|
148
|
+
|
|
149
|
+
const lines = sentTexts(out.effects);
|
|
150
|
+
expect(lines).toContain(
|
|
151
|
+
':HostServ!HostServ@services NOTICE bob :Available commands: ON, OFF, REQUEST',
|
|
152
|
+
);
|
|
153
|
+
const helpLine = lines.find((t) => t.includes('Available commands'));
|
|
154
|
+
for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
|
|
155
|
+
expect(helpLine).not.toContain(cmd);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('lists the full command set to an oper on empty body', () => {
|
|
160
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
161
|
+
const conn = operConn('admin');
|
|
162
|
+
const ctx = makeCtx(conn, services);
|
|
163
|
+
|
|
164
|
+
const out = hostservReducer(conn, privmsg(''), ctx);
|
|
165
|
+
|
|
166
|
+
expect(sentTexts(out.effects)).toContain(
|
|
167
|
+
':HostServ!HostServ@services NOTICE admin :Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('hides oper-only commands from a non-oper on an unknown subcommand', () => {
|
|
172
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
173
|
+
const conn = identifiedConn('bob');
|
|
174
|
+
const ctx = makeCtx(conn, services);
|
|
175
|
+
|
|
176
|
+
const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
|
|
177
|
+
|
|
178
|
+
const lines = sentTexts(out.effects);
|
|
179
|
+
expect(lines).toContain(
|
|
180
|
+
':HostServ!HostServ@services NOTICE bob :Unknown command. Available: ON, OFF, REQUEST',
|
|
181
|
+
);
|
|
182
|
+
const unknownLine = lines.find((t) => t.includes('Unknown command'));
|
|
183
|
+
for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
|
|
184
|
+
expect(unknownLine).not.toContain(cmd);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('lists the full command set to an oper on an unknown subcommand', () => {
|
|
189
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
190
|
+
const conn = operConn('admin');
|
|
191
|
+
const ctx = makeCtx(conn, services);
|
|
192
|
+
|
|
193
|
+
const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
|
|
194
|
+
|
|
195
|
+
expect(sentTexts(out.effects)).toContain(
|
|
196
|
+
':HostServ!HostServ@services NOTICE admin :Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('still rejects a non-oper who guesses an oper-only command (LIST)', () => {
|
|
201
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
202
|
+
services.requestVhost('alice', 'cool.example.net');
|
|
203
|
+
const conn = identifiedConn('bob');
|
|
204
|
+
const ctx = makeCtx(conn, services);
|
|
205
|
+
|
|
206
|
+
const out = hostservReducer(conn, privmsg('LIST'), ctx);
|
|
207
|
+
|
|
208
|
+
expect(sentTexts(out.effects)).toContain(
|
|
209
|
+
':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
142
213
|
it('updates lastSeen to ctx.clock.now()', () => {
|
|
143
214
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
144
215
|
const conn = identifiedConn('alice');
|
|
@@ -1064,6 +1064,83 @@ describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
|
|
|
1064
1064
|
});
|
|
1065
1065
|
});
|
|
1066
1066
|
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
// joinReducer — TOPIC numerics (332/333) on JOIN
|
|
1069
|
+
// ============================================================================
|
|
1070
|
+
//
|
|
1071
|
+
// RFC 2812 §3.2.1: after a successful JOIN the server sends 332 RPL_TOPIC
|
|
1072
|
+
// and 333 RPL_TOPICWHOTIME whenever a topic is set, regardless of how the
|
|
1073
|
+
// topic reached the authoritative ChannelState (KEEPTOPIC restore, a
|
|
1074
|
+
// deploy snapshot, or an earlier joiner). These tests pin the behaviour
|
|
1075
|
+
// that was previously missing for every non-KEEPTOPIC-restore code path.
|
|
1076
|
+
// ============================================================================
|
|
1077
|
+
|
|
1078
|
+
describe('joinReducer — TOPIC numerics on JOIN', () => {
|
|
1079
|
+
it('emits 332/333 to the joiner when state.topic is already set (deploy snapshot)', () => {
|
|
1080
|
+
const chan = makeChan('#foo');
|
|
1081
|
+
// Simulate a topic that survived a deploy in storage and was loaded
|
|
1082
|
+
// onto the authoritative ChannelState before this JOIN (empty roster,
|
|
1083
|
+
// no KEEPTOPIC restore path taken).
|
|
1084
|
+
chan.topic = { text: 'persisted topic', setter: 'alice!alice@example.com', setAt: 5_000 };
|
|
1085
|
+
const conn = makeConn('c1', 'bob');
|
|
1086
|
+
const ctx = makeCtx(conn);
|
|
1087
|
+
|
|
1088
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1089
|
+
|
|
1090
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1091
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :persisted topic');
|
|
1092
|
+
expect(
|
|
1093
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 5000')),
|
|
1094
|
+
).toBe(true);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('emits 332/333 to a second joiner when the topic was set by an earlier joiner', () => {
|
|
1098
|
+
const chan = makeChan('#foo');
|
|
1099
|
+
addMember(chan, 'c0', 'alice', true);
|
|
1100
|
+
// Topic set by an earlier joiner on a non-empty channel.
|
|
1101
|
+
chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 1_000 };
|
|
1102
|
+
const conn = makeConn('c1', 'bob');
|
|
1103
|
+
const ctx = makeCtx(conn);
|
|
1104
|
+
|
|
1105
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1106
|
+
|
|
1107
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1108
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :hello world');
|
|
1109
|
+
expect(
|
|
1110
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 1000')),
|
|
1111
|
+
).toBe(true);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('emits 332/333 after the 353/366 NAMES list (existing emission order)', () => {
|
|
1115
|
+
const chan = makeChan('#foo');
|
|
1116
|
+
chan.topic = { text: 't', setter: 's', setAt: 1 };
|
|
1117
|
+
const conn = makeConn('c1', 'bob');
|
|
1118
|
+
const ctx = makeCtx(conn);
|
|
1119
|
+
|
|
1120
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1121
|
+
|
|
1122
|
+
const send = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1123
|
+
const idx353 = send.findIndex((t) => t.includes(' 353 '));
|
|
1124
|
+
const idx366 = send.findIndex((t) => t.includes(' 366 '));
|
|
1125
|
+
const idx332 = send.findIndex((t) => t.includes(' 332 '));
|
|
1126
|
+
expect(idx353).toBeGreaterThanOrEqual(0);
|
|
1127
|
+
expect(idx366).toBeGreaterThan(idx353);
|
|
1128
|
+
expect(idx332).toBeGreaterThan(idx366);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('does not emit 332/333 when no topic is set', () => {
|
|
1132
|
+
const chan = makeChan('#foo');
|
|
1133
|
+
const conn = makeConn();
|
|
1134
|
+
const ctx = makeCtx(conn);
|
|
1135
|
+
|
|
1136
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1137
|
+
|
|
1138
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1139
|
+
expect(texts.some((t) => t.includes(' 332 '))).toBe(false);
|
|
1140
|
+
expect(texts.some((t) => t.includes(' 333 '))).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1067
1144
|
// ============================================================================
|
|
1068
1145
|
// joinReducer — ChanServ ACCESS auto-op / auto-voice
|
|
1069
1146
|
// ============================================================================
|
|
@@ -1269,6 +1346,108 @@ describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
|
|
|
1269
1346
|
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1270
1347
|
});
|
|
1271
1348
|
|
|
1349
|
+
it('does not auto-op the first joiner of an empty registered channel when off the access list', () => {
|
|
1350
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1351
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1352
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1353
|
+
services.registerChannel('#foo', 'alice');
|
|
1354
|
+
const chan = makeChan('#foo');
|
|
1355
|
+
expect(chan.members.size).toBe(0);
|
|
1356
|
+
const conn = makeConn('c1', 'bob');
|
|
1357
|
+
conn.account = 'bob';
|
|
1358
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1359
|
+
|
|
1360
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1361
|
+
|
|
1362
|
+
const entry = out.state.members.get('c1');
|
|
1363
|
+
expect(entry?.op).toBe(false);
|
|
1364
|
+
expect(entry?.voice).toBe(false);
|
|
1365
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1366
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1367
|
+
);
|
|
1368
|
+
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1369
|
+
expect(broadcasts.some((t) => t.includes('+o bob'))).toBe(false);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('auto-ops the first joiner of an empty registered channel when they hold AUTOOP-level access', () => {
|
|
1373
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1374
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1375
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1376
|
+
services.registerChannel('#foo', 'alice');
|
|
1377
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1378
|
+
const chan = makeChan('#foo');
|
|
1379
|
+
expect(chan.members.size).toBe(0);
|
|
1380
|
+
const conn = makeConn('c1', 'bob');
|
|
1381
|
+
conn.account = 'bob';
|
|
1382
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1383
|
+
|
|
1384
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1385
|
+
|
|
1386
|
+
const entry = out.state.members.get('c1');
|
|
1387
|
+
expect(entry?.op).toBe(true);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('does not auto-op an unidentified first joiner of an empty registered channel', () => {
|
|
1391
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1392
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1393
|
+
services.registerChannel('#foo', 'alice');
|
|
1394
|
+
const chan = makeChan('#foo');
|
|
1395
|
+
expect(chan.members.size).toBe(0);
|
|
1396
|
+
const conn = makeConn('c1', 'bob');
|
|
1397
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1398
|
+
|
|
1399
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1400
|
+
|
|
1401
|
+
const entry = out.state.members.get('c1');
|
|
1402
|
+
expect(entry?.op).toBe(false);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('still auto-ops the founder first-joining an empty registered channel', () => {
|
|
1406
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1407
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1408
|
+
services.registerChannel('#foo', 'alice');
|
|
1409
|
+
const chan = makeChan('#foo');
|
|
1410
|
+
expect(chan.members.size).toBe(0);
|
|
1411
|
+
const conn = makeConn('c1', 'alice');
|
|
1412
|
+
conn.account = 'alice';
|
|
1413
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1414
|
+
|
|
1415
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1416
|
+
|
|
1417
|
+
const entry = out.state.members.get('c1');
|
|
1418
|
+
expect(entry?.op).toBe(true);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('auto-ops the first joiner of an empty channel when services are bound but the channel is unregistered', () => {
|
|
1422
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1423
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1424
|
+
const chan = makeChan('#foo');
|
|
1425
|
+
expect(chan.members.size).toBe(0);
|
|
1426
|
+
const conn = makeConn('c1', 'bob');
|
|
1427
|
+
conn.account = 'bob';
|
|
1428
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1429
|
+
|
|
1430
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1431
|
+
|
|
1432
|
+
const entry = out.state.members.get('c1');
|
|
1433
|
+
expect(entry?.op).toBe(true);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('auto-ops the first joiner of an empty channel when no services store is bound', () => {
|
|
1437
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1438
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1439
|
+
const chan = makeChan('#foo');
|
|
1440
|
+
expect(chan.members.size).toBe(0);
|
|
1441
|
+
const conn = makeConn('c1', 'bob');
|
|
1442
|
+
conn.account = 'bob';
|
|
1443
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, undefined);
|
|
1444
|
+
|
|
1445
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1446
|
+
|
|
1447
|
+
const entry = out.state.members.get('c1');
|
|
1448
|
+
expect(entry?.op).toBe(true);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1272
1451
|
it('does nothing when no services store is bound', () => {
|
|
1273
1452
|
const chan = makeChan('#foo');
|
|
1274
1453
|
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
@@ -115,6 +115,24 @@ describe('markreadChannelReducer — cap gating', () => {
|
|
|
115
115
|
Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
|
|
116
116
|
]);
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
it('substitutes * for the nick in the 421 numeric when the connection has no nick', () => {
|
|
120
|
+
const chan = makeChan();
|
|
121
|
+
// A bare connection: no nick set and no draft/read-marker cap negotiated.
|
|
122
|
+
// The 421 numeric's `ctx.connection.nick ?? '*'` falls back to `*`.
|
|
123
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
124
|
+
const ctx = makeCtx(conn);
|
|
125
|
+
|
|
126
|
+
const out = markreadChannelReducer(
|
|
127
|
+
chan,
|
|
128
|
+
{ command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
133
|
+
Effect.send('c1', [L(':irc.example.com 421 * MARKREAD :Unknown command')]),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
118
136
|
});
|
|
119
137
|
|
|
120
138
|
// ============================================================================
|
|
@@ -168,6 +186,42 @@ describe('markreadChannelReducer — parameter errors', () => {
|
|
|
168
186
|
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
169
187
|
]);
|
|
170
188
|
});
|
|
189
|
+
|
|
190
|
+
it('FAILs with INVALID_PARAMS when the timestamp prefix is present but the value is empty', () => {
|
|
191
|
+
const chan = makeChan();
|
|
192
|
+
addMember(chan, 'c1', 'alice');
|
|
193
|
+
const store = new InMemoryMessageStore();
|
|
194
|
+
seed(store, '#foo', 3);
|
|
195
|
+
const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
|
|
196
|
+
|
|
197
|
+
const out = markreadChannelReducer(
|
|
198
|
+
chan,
|
|
199
|
+
{ command: 'MARKREAD', params: ['#foo', 'timestamp='], tags: {} },
|
|
200
|
+
ctx,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
204
|
+
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('FAILs with INVALID_PARAMS when the timestamp value is not a parseable date', () => {
|
|
209
|
+
const chan = makeChan();
|
|
210
|
+
addMember(chan, 'c1', 'alice');
|
|
211
|
+
const store = new InMemoryMessageStore();
|
|
212
|
+
seed(store, '#foo', 3);
|
|
213
|
+
const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
|
|
214
|
+
|
|
215
|
+
const out = markreadChannelReducer(
|
|
216
|
+
chan,
|
|
217
|
+
{ command: 'MARKREAD', params: ['#foo', 'timestamp=not-a-date'], tags: {} },
|
|
218
|
+
ctx,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
222
|
+
Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
171
225
|
});
|
|
172
226
|
|
|
173
227
|
// ============================================================================
|
|
@@ -71,6 +71,25 @@ describe('memoservReducer — services availability', () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
describe('memoservReducer — empty body', () => {
|
|
75
|
+
it('replies with the help notice when the PRIVMSG carries no body parameter', () => {
|
|
76
|
+
const conn = makeConn();
|
|
77
|
+
const services = new InMemoryServicesStore();
|
|
78
|
+
const ctx = makeCtx(conn, services);
|
|
79
|
+
// params: [MEMOSERV_NICK] only — params[1] is undefined, exercising the
|
|
80
|
+
// `msg.params[1] ?? ''` fallback. The empty body resolves to an empty
|
|
81
|
+
// subcommand, which emits the help notice.
|
|
82
|
+
const out = memoservReducer(
|
|
83
|
+
conn,
|
|
84
|
+
{ command: 'PRIVMSG', params: [MEMOSERV_NICK], tags: {} },
|
|
85
|
+
ctx,
|
|
86
|
+
);
|
|
87
|
+
expect(sentTexts(out.effects)).toContain(
|
|
88
|
+
':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
74
93
|
describe('memoservReducer — SEND', () => {
|
|
75
94
|
it('requires the sender to be identified', () => {
|
|
76
95
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|