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
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type AccountStore,
|
|
3
2
|
type AwayStore,
|
|
4
3
|
CapturingLogger,
|
|
5
4
|
type ChanName,
|
|
@@ -26,8 +25,6 @@ import {
|
|
|
26
25
|
type NickHistoryStore,
|
|
27
26
|
Numerics,
|
|
28
27
|
type RawLine,
|
|
29
|
-
type SaslPayload,
|
|
30
|
-
type SaslResult,
|
|
31
28
|
SequentialIdFactory,
|
|
32
29
|
type ServerConfig,
|
|
33
30
|
type ServerStats,
|
|
@@ -219,7 +216,6 @@ function makeActor(opts: {
|
|
|
219
216
|
logger?: Logger;
|
|
220
217
|
traceId?: string;
|
|
221
218
|
messages?: MessageStore;
|
|
222
|
-
accounts?: AccountStore;
|
|
223
219
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
224
220
|
history?: NickHistoryStore;
|
|
225
221
|
stats?: ServerStats;
|
|
@@ -280,9 +276,6 @@ function makeActor(opts: {
|
|
|
280
276
|
if (opts.messages !== undefined) {
|
|
281
277
|
finalOpts.messages = opts.messages;
|
|
282
278
|
}
|
|
283
|
-
if (opts.accounts !== undefined) {
|
|
284
|
-
finalOpts.accounts = opts.accounts;
|
|
285
|
-
}
|
|
286
279
|
if (opts.mtlsIdentity !== undefined) {
|
|
287
280
|
finalOpts.mtlsIdentity = opts.mtlsIdentity;
|
|
288
281
|
}
|
|
@@ -908,12 +901,13 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
908
901
|
expect(lines[0]?.text).toContain('462');
|
|
909
902
|
});
|
|
910
903
|
|
|
911
|
-
it('routes PASS to passReducer (
|
|
904
|
+
it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
|
|
912
905
|
const { actor, runtime } = makeActor({});
|
|
913
906
|
await actor.receiveTextFrame('PASS sekrat\r\n');
|
|
914
|
-
// passReducer on
|
|
907
|
+
// passReducer on a registered conn with no AccountStore treats a bare
|
|
908
|
+
// server-password PASS as a silent no-op (no 462, no disconnect).
|
|
915
909
|
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
916
|
-
expect(sends).toHaveLength(
|
|
910
|
+
expect(sends).toHaveLength(0);
|
|
917
911
|
});
|
|
918
912
|
|
|
919
913
|
it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
|
|
@@ -2128,17 +2122,7 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
2128
2122
|
// SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
|
|
2129
2123
|
// ===========================================================================
|
|
2130
2124
|
|
|
2131
|
-
describe('ConnectionActor — SASL
|
|
2132
|
-
/** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
|
|
2133
|
-
class FakeAccountStore implements AccountStore {
|
|
2134
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
2135
|
-
constructor(private readonly result: SaslResult) {}
|
|
2136
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
2137
|
-
this.calls.push({ mech, payload });
|
|
2138
|
-
return this.result;
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2125
|
+
describe('ConnectionActor — SASL ServicesStore plumbing', () => {
|
|
2142
2126
|
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
2143
2127
|
function plainPayload(authcid: string, password: string): string {
|
|
2144
2128
|
return encodeBase64(`\0${authcid}\0${password}`);
|
|
@@ -2153,11 +2137,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2153
2137
|
};
|
|
2154
2138
|
}
|
|
2155
2139
|
|
|
2156
|
-
it('completes SASL PLAIN with 900/903 when a bound
|
|
2157
|
-
const
|
|
2140
|
+
it('completes SASL PLAIN with 900/903 when a bound ServicesStore accepts creds', async () => {
|
|
2141
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2142
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2158
2143
|
const { actor, runtime, conn } = makeActor({
|
|
2159
2144
|
conn: saslReadyConn(),
|
|
2160
|
-
|
|
2145
|
+
services,
|
|
2161
2146
|
});
|
|
2162
2147
|
conn.nick = 'alice';
|
|
2163
2148
|
runtime.calls.length = 0;
|
|
@@ -2172,18 +2157,14 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2172
2157
|
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
2173
2158
|
// Critical: never 904 for valid creds.
|
|
2174
2159
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2175
|
-
// The AccountStore was consulted with the parsed PLAIN credentials.
|
|
2176
|
-
expect(accounts.calls).toEqual([
|
|
2177
|
-
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
2178
|
-
]);
|
|
2179
2160
|
// Account name recorded on the connection for extended-join/account-tag.
|
|
2180
2161
|
expect(conn.account).toBe('alice');
|
|
2181
2162
|
});
|
|
2182
2163
|
|
|
2183
|
-
it('emits 904 ERR_SASLFAIL when no
|
|
2164
|
+
it('emits 904 ERR_SASLFAIL when no ServicesStore is bound (regression guard)', async () => {
|
|
2184
2165
|
const { actor, runtime, conn } = makeActor({
|
|
2185
2166
|
conn: saslReadyConn(),
|
|
2186
|
-
// No `
|
|
2167
|
+
// No `services` option → ctx.services is undefined.
|
|
2187
2168
|
});
|
|
2188
2169
|
conn.nick = 'alice';
|
|
2189
2170
|
runtime.calls.length = 0;
|
|
@@ -2201,11 +2182,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2201
2182
|
expect(conn.account).toBeUndefined();
|
|
2202
2183
|
});
|
|
2203
2184
|
|
|
2204
|
-
it('emits 904 when a bound
|
|
2205
|
-
const
|
|
2185
|
+
it('emits 904 when a bound ServicesStore rejects the credentials', async () => {
|
|
2186
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2187
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2206
2188
|
const { actor, runtime, conn } = makeActor({
|
|
2207
2189
|
conn: saslReadyConn(),
|
|
2208
|
-
|
|
2190
|
+
services,
|
|
2209
2191
|
});
|
|
2210
2192
|
conn.nick = 'alice';
|
|
2211
2193
|
runtime.calls.length = 0;
|
|
@@ -2222,16 +2204,6 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2222
2204
|
});
|
|
2223
2205
|
|
|
2224
2206
|
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
2225
|
-
/** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
|
|
2226
|
-
class FakeAccountStore implements AccountStore {
|
|
2227
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
2228
|
-
constructor(private readonly result: SaslResult) {}
|
|
2229
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
2230
|
-
this.calls.push({ mech, payload });
|
|
2231
|
-
return this.result;
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
2207
|
/** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
|
|
2236
2208
|
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
2237
2209
|
constructor(private readonly identity: string | undefined) {}
|
|
@@ -2249,11 +2221,13 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2249
2221
|
}
|
|
2250
2222
|
|
|
2251
2223
|
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
2252
|
-
const
|
|
2224
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2225
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2226
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2253
2227
|
const mtlsIdentity = new FakeMtlsProvider('CN=alice');
|
|
2254
2228
|
const { actor, runtime, conn } = makeActor({
|
|
2255
2229
|
conn: saslReadyConn(),
|
|
2256
|
-
|
|
2230
|
+
services,
|
|
2257
2231
|
mtlsIdentity,
|
|
2258
2232
|
});
|
|
2259
2233
|
conn.nick = 'alice';
|
|
@@ -2267,17 +2241,16 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2267
2241
|
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
2268
2242
|
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
2269
2243
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2270
|
-
expect(accounts.calls).toEqual([
|
|
2271
|
-
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
2272
|
-
]);
|
|
2273
2244
|
expect(conn.account).toBe('alice');
|
|
2274
2245
|
});
|
|
2275
2246
|
|
|
2276
2247
|
it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
|
|
2277
|
-
const
|
|
2248
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2249
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2250
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2278
2251
|
const { actor, runtime, conn } = makeActor({
|
|
2279
2252
|
conn: saslReadyConn(),
|
|
2280
|
-
|
|
2253
|
+
services,
|
|
2281
2254
|
// No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
|
|
2282
2255
|
});
|
|
2283
2256
|
conn.nick = 'alice';
|
|
@@ -4261,6 +4234,39 @@ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
|
|
|
4261
4234
|
const lines = sends[0]?.args[1] as RawLine[];
|
|
4262
4235
|
expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
|
|
4263
4236
|
});
|
|
4237
|
+
|
|
4238
|
+
it('silently ignores a nick-target MARKREAD (read markers are channel-scoped)', async () => {
|
|
4239
|
+
const store = new InMemoryMessageStore();
|
|
4240
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4241
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4242
|
+
runtime.calls.length = 0;
|
|
4243
|
+
|
|
4244
|
+
// A nick (non-channel) target falls through to the `return []` arm — the
|
|
4245
|
+
// deployment has no nick-scoped read markers, so the verb is a no-op
|
|
4246
|
+
// rather than a 421 or a FAIL.
|
|
4247
|
+
await actor.receiveTextFrame(`MARKREAD alice ${TS(2_000)}\r\n`);
|
|
4248
|
+
|
|
4249
|
+
expect(runtime.calls).toHaveLength(0);
|
|
4250
|
+
});
|
|
4251
|
+
|
|
4252
|
+
it('routes a parameterless MARKREAD through the reducer (NEED_MORE_PARAMS, never 421 fallthrough)', async () => {
|
|
4253
|
+
const store = new InMemoryMessageStore();
|
|
4254
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4255
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4256
|
+
runtime.calls.length = 0;
|
|
4257
|
+
|
|
4258
|
+
// No params at all → `msg.params[0] ?? ''` falls back to the empty
|
|
4259
|
+
// string, which is routed into the reducer so it can FAIL
|
|
4260
|
+
// NEED_MORE_PARAMS rather than hitting the generic 421 fallthrough.
|
|
4261
|
+
await actor.receiveTextFrame('MARKREAD\r\n');
|
|
4262
|
+
|
|
4263
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4264
|
+
expect(sends).toHaveLength(1);
|
|
4265
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4266
|
+
expect(lines[0]?.text).toContain('NEED_MORE_PARAMS');
|
|
4267
|
+
// Critical: never the generic 421 fallthrough for a cap-enabled client.
|
|
4268
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
4269
|
+
});
|
|
4264
4270
|
});
|
|
4265
4271
|
|
|
4266
4272
|
describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
|
|
@@ -4978,3 +4984,331 @@ describe('ConnectionActor — OperServ routing', () => {
|
|
|
4978
4984
|
);
|
|
4979
4985
|
});
|
|
4980
4986
|
});
|
|
4987
|
+
|
|
4988
|
+
// ============================================================================
|
|
4989
|
+
// echo-message — services PRIVMSG echo (IRCv3)
|
|
4990
|
+
//
|
|
4991
|
+
// When a client that negotiated `echo-message` sends `PRIVMSG <ServiceNick>`
|
|
4992
|
+
// the server must echo the sender's own PRIVMSG line back, BEFORE the service
|
|
4993
|
+
// reducer's NOTICE reply — matching how every other PRIVMSG renders (your
|
|
4994
|
+
// outgoing line first, then any reply). Without this echo,
|
|
4995
|
+
// modern IRCv3 clients optimistically show the outgoing message as "sending"
|
|
4996
|
+
// and flip it to "failed" when no delivery confirmation arrives.
|
|
4997
|
+
// ============================================================================
|
|
4998
|
+
|
|
4999
|
+
describe('ConnectionActor — echo-message on service PRIVMSG', () => {
|
|
5000
|
+
function makeOnlineConn(id: string, nick: string): ConnectionState {
|
|
5001
|
+
const c = createConnection({ id, connectedSince: 0 });
|
|
5002
|
+
c.nick = nick;
|
|
5003
|
+
c.user = nick;
|
|
5004
|
+
c.host = `${nick}.org`;
|
|
5005
|
+
c.realname = nick;
|
|
5006
|
+
c.registration = 'registered';
|
|
5007
|
+
return c;
|
|
5008
|
+
}
|
|
5009
|
+
|
|
5010
|
+
it('echoes the sender PRIVMSG back when echo-message is negotiated (NickServ)', async () => {
|
|
5011
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5012
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5013
|
+
const { actor, runtime } = makeActor({
|
|
5014
|
+
services,
|
|
5015
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5016
|
+
});
|
|
5017
|
+
runtime.calls.length = 0;
|
|
5018
|
+
|
|
5019
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5020
|
+
|
|
5021
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5022
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5023
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5024
|
+
});
|
|
5025
|
+
|
|
5026
|
+
it('does NOT echo when echo-message is not negotiated', async () => {
|
|
5027
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5028
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5029
|
+
const { actor, runtime } = makeActor({ services });
|
|
5030
|
+
runtime.calls.length = 0;
|
|
5031
|
+
|
|
5032
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5033
|
+
|
|
5034
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5035
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5036
|
+
for (const t of allText) {
|
|
5037
|
+
expect(t).not.toMatch(/PRIVMSG NickServ :IDENTIFY hunter2$/);
|
|
5038
|
+
}
|
|
5039
|
+
});
|
|
5040
|
+
|
|
5041
|
+
it('emits the echo BEFORE the NickServ NOTICE reply (ordering)', async () => {
|
|
5042
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5043
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5044
|
+
const { actor, runtime } = makeActor({
|
|
5045
|
+
services,
|
|
5046
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5047
|
+
});
|
|
5048
|
+
runtime.calls.length = 0;
|
|
5049
|
+
|
|
5050
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5051
|
+
|
|
5052
|
+
const sendCalls = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5053
|
+
const findIdx = (needle: string): number =>
|
|
5054
|
+
sendCalls.findIndex((c) => (c.args[1] as RawLine[]).some((l) => l.text.includes(needle)));
|
|
5055
|
+
const noticeIdx = findIdx('NickServ!NickServ@services NOTICE');
|
|
5056
|
+
const echoIdx = findIdx('PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5057
|
+
expect(noticeIdx).toBeGreaterThanOrEqual(0);
|
|
5058
|
+
expect(echoIdx).toBeGreaterThanOrEqual(0);
|
|
5059
|
+
expect(echoIdx).toBeLessThan(noticeIdx);
|
|
5060
|
+
});
|
|
5061
|
+
|
|
5062
|
+
it('echoes the sender PRIVMSG back for ChanServ (shared fix, not NickServ-specific)', async () => {
|
|
5063
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5064
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5065
|
+
const { actor, runtime } = makeActor({
|
|
5066
|
+
services,
|
|
5067
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5068
|
+
});
|
|
5069
|
+
runtime.calls.length = 0;
|
|
5070
|
+
|
|
5071
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :HELP\r\n');
|
|
5072
|
+
|
|
5073
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5074
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5075
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG ChanServ :HELP');
|
|
5076
|
+
});
|
|
5077
|
+
|
|
5078
|
+
it('preserves the sender target casing in the echo', async () => {
|
|
5079
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5080
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5081
|
+
const { actor, runtime } = makeActor({
|
|
5082
|
+
services,
|
|
5083
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5084
|
+
});
|
|
5085
|
+
runtime.calls.length = 0;
|
|
5086
|
+
|
|
5087
|
+
await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
|
|
5088
|
+
|
|
5089
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5090
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5091
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG nickserv :IDENTIFY hunter2');
|
|
5092
|
+
});
|
|
5093
|
+
|
|
5094
|
+
it('does NOT intercept PRIVMSG to a non-service nick when services are bound', async () => {
|
|
5095
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5096
|
+
const { actor, runtime } = makeActor({ services });
|
|
5097
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
5098
|
+
runtime.calls.length = 0;
|
|
5099
|
+
|
|
5100
|
+
await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
|
|
5101
|
+
|
|
5102
|
+
// With services bound, a non-service nick still routes through the normal
|
|
5103
|
+
// user-target path (sendToNick), never the service reducer or echo path.
|
|
5104
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
5105
|
+
expect(toNick).toHaveLength(1);
|
|
5106
|
+
});
|
|
5107
|
+
|
|
5108
|
+
it('echoes with empty text when the PRIVMSG carries no trailing parameter', async () => {
|
|
5109
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5110
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5111
|
+
const { actor, runtime } = makeActor({
|
|
5112
|
+
services,
|
|
5113
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5114
|
+
});
|
|
5115
|
+
runtime.calls.length = 0;
|
|
5116
|
+
|
|
5117
|
+
// No `:text` argument → `singleParamMsg.params[1] ?? ''` falls back to an
|
|
5118
|
+
// empty string in the echo line.
|
|
5119
|
+
await actor.receiveTextFrame('PRIVMSG NickServ\r\n');
|
|
5120
|
+
|
|
5121
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5122
|
+
const echo = toAlice
|
|
5123
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5124
|
+
.find((t) => t.endsWith('PRIVMSG NickServ :'));
|
|
5125
|
+
expect(echo).toBe(':alice!alice@example.com PRIVMSG NickServ :');
|
|
5126
|
+
});
|
|
5127
|
+
|
|
5128
|
+
it('falls back to "?" hostmask in the echo when the sender has no nick', async () => {
|
|
5129
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5130
|
+
const { actor, runtime, conn } = makeActor({
|
|
5131
|
+
services,
|
|
5132
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5133
|
+
});
|
|
5134
|
+
// A connection that negotiated echo-message but has no NICK/USER/HOST —
|
|
5135
|
+
// exercises the `hostmaskOf(state) ?? state.nick ?? '?'` fallback chain
|
|
5136
|
+
// in the service-PRIVMSG echo. `delete` is required because
|
|
5137
|
+
// exactOptionalPropertyTypes forbids `= undefined`.
|
|
5138
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5139
|
+
delete conn.nick;
|
|
5140
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5141
|
+
delete conn.user;
|
|
5142
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5143
|
+
delete conn.host;
|
|
5144
|
+
runtime.calls.length = 0;
|
|
5145
|
+
|
|
5146
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5147
|
+
|
|
5148
|
+
const toSender = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5149
|
+
const echo = toSender
|
|
5150
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5151
|
+
.find((t) => t.includes('PRIVMSG NickServ :IDENTIFY hunter2'));
|
|
5152
|
+
// Both `??` arms fire: hostmaskOf returns undefined (no nick), then
|
|
5153
|
+
// state.nick is undefined, so the final '?' fallback is used.
|
|
5154
|
+
expect(echo).toBe(':? PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5155
|
+
});
|
|
5156
|
+
});
|
|
5157
|
+
|
|
5158
|
+
// ============================================================================
|
|
5159
|
+
// Service shortcut commands — NICKSERV/NS/CHANSERV/CS/HOSTSERV/HS/MEMOSERV/MS/
|
|
5160
|
+
// OPERSERV/OS (Atheme/Anope-convention aliases rewritten to
|
|
5161
|
+
// PRIVMSG <ServiceNick> :<joined args> by the dispatcher).
|
|
5162
|
+
// ============================================================================
|
|
5163
|
+
|
|
5164
|
+
describe('ConnectionActor — service shortcut commands', () => {
|
|
5165
|
+
it('NICKSERV IDENTIFY behaves identically to PRIVMSG NickServ :IDENTIFY (never 421)', async () => {
|
|
5166
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5167
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5168
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
5169
|
+
runtime.calls.length = 0;
|
|
5170
|
+
|
|
5171
|
+
await actor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5172
|
+
|
|
5173
|
+
expect(conn.account).toBe('alice');
|
|
5174
|
+
expect(conn.userModes.registered).toBe(true);
|
|
5175
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5176
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5177
|
+
expect(allText).toContain(
|
|
5178
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
5179
|
+
);
|
|
5180
|
+
// Critical: the shortcut must reach the reducer, never the 421 fallthrough.
|
|
5181
|
+
for (const t of allText) {
|
|
5182
|
+
expect(t).not.toContain(' 421 ');
|
|
5183
|
+
}
|
|
5184
|
+
});
|
|
5185
|
+
|
|
5186
|
+
// Each alias must resolve to its service. Sending the bare alias (no args)
|
|
5187
|
+
// reaches the reducer, which emits a help NOTICE sourced from the matching
|
|
5188
|
+
// pseudo-client — so the NOTICE source nick identifies the routed service.
|
|
5189
|
+
const aliasToService: ReadonlyArray<[string, string]> = [
|
|
5190
|
+
['NICKSERV', 'NickServ'],
|
|
5191
|
+
['NS', 'NickServ'],
|
|
5192
|
+
['CHANSERV', 'ChanServ'],
|
|
5193
|
+
['CS', 'ChanServ'],
|
|
5194
|
+
['HOSTSERV', 'HostServ'],
|
|
5195
|
+
['HS', 'HostServ'],
|
|
5196
|
+
['MEMOSERV', 'MemoServ'],
|
|
5197
|
+
['MS', 'MemoServ'],
|
|
5198
|
+
['OPERSERV', 'OperServ'],
|
|
5199
|
+
['OS', 'OperServ'],
|
|
5200
|
+
];
|
|
5201
|
+
|
|
5202
|
+
for (const [alias, service] of aliasToService) {
|
|
5203
|
+
it(`${alias} (no args) routes to ${service} via the help NOTICE`, async () => {
|
|
5204
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5205
|
+
const { actor, runtime } = makeActor({ services });
|
|
5206
|
+
runtime.calls.length = 0;
|
|
5207
|
+
|
|
5208
|
+
await actor.receiveTextFrame(`${alias}\r\n`);
|
|
5209
|
+
|
|
5210
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5211
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5212
|
+
// The help NOTICE source hostmask identifies which service was routed.
|
|
5213
|
+
expect(
|
|
5214
|
+
allText.some((t) => t.startsWith(`:${service}!${service}@services NOTICE alice :`)),
|
|
5215
|
+
).toBe(true);
|
|
5216
|
+
// And it never produces a 421.
|
|
5217
|
+
for (const t of allText) {
|
|
5218
|
+
expect(t).not.toContain(' 421 ');
|
|
5219
|
+
}
|
|
5220
|
+
});
|
|
5221
|
+
}
|
|
5222
|
+
|
|
5223
|
+
it('aliases are case-insensitive (ns lower-case maps to NickServ)', async () => {
|
|
5224
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5225
|
+
const { actor, runtime } = makeActor({ services });
|
|
5226
|
+
runtime.calls.length = 0;
|
|
5227
|
+
|
|
5228
|
+
await actor.receiveTextFrame('ns\r\n');
|
|
5229
|
+
|
|
5230
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5231
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5232
|
+
expect(allText.some((t) => t.startsWith(':NickServ!NickServ@services NOTICE alice :'))).toBe(
|
|
5233
|
+
true,
|
|
5234
|
+
);
|
|
5235
|
+
});
|
|
5236
|
+
|
|
5237
|
+
it('joins multi-word args into the PRIVMSG trailing body (NICKSERV REGISTER pw email)', async () => {
|
|
5238
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5239
|
+
const { actor, runtime } = makeActor({ services });
|
|
5240
|
+
runtime.calls.length = 0;
|
|
5241
|
+
|
|
5242
|
+
// Three trailing tokens must be joined and delivered as the body, exactly
|
|
5243
|
+
// mirroring `PRIVMSG NickServ :REGISTER hunter2 alice@example.com`.
|
|
5244
|
+
await actor.receiveTextFrame('NICKSERV REGISTER hunter2 alice@example.com\r\n');
|
|
5245
|
+
|
|
5246
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
5247
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5248
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5249
|
+
expect(allText).toContain(
|
|
5250
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
|
|
5251
|
+
);
|
|
5252
|
+
});
|
|
5253
|
+
|
|
5254
|
+
it('joins a colon-trailing arg shape into the PRIVMSG body (NS :IDENTIFY hunter2)', async () => {
|
|
5255
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5256
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5257
|
+
const { actor, conn } = makeActor({ services });
|
|
5258
|
+
|
|
5259
|
+
// `NS :IDENTIFY hunter2` parses to params=['IDENTIFY hunter2']; joining
|
|
5260
|
+
// reproduces the body `IDENTIFY hunter2` so the identify succeeds.
|
|
5261
|
+
await actor.receiveTextFrame('NS :IDENTIFY hunter2\r\n');
|
|
5262
|
+
|
|
5263
|
+
expect(conn.account).toBe('alice');
|
|
5264
|
+
});
|
|
5265
|
+
|
|
5266
|
+
it('shortcut with no args is equivalent to PRIVMSG <ServiceNick> (help NOTICE)', async () => {
|
|
5267
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5268
|
+
const { actor: a1, runtime: r1 } = makeActor({ services });
|
|
5269
|
+
r1.calls.length = 0;
|
|
5270
|
+
await a1.receiveTextFrame('CS\r\n');
|
|
5271
|
+
const shortcutText = r1.calls
|
|
5272
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5273
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5274
|
+
|
|
5275
|
+
const { actor: a2, runtime: r2 } = makeActor({ services });
|
|
5276
|
+
r2.calls.length = 0;
|
|
5277
|
+
await a2.receiveTextFrame('PRIVMSG ChanServ :\r\n');
|
|
5278
|
+
const privmsgText = r2.calls
|
|
5279
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5280
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5281
|
+
|
|
5282
|
+
expect(shortcutText).toEqual(privmsgText);
|
|
5283
|
+
});
|
|
5284
|
+
|
|
5285
|
+
it('when services are unavailable, the shortcut matches a direct PRIVMSG to the service (401, never 421)', async () => {
|
|
5286
|
+
// No services store bound → the rewritten PRIVMSG NickServ falls through
|
|
5287
|
+
// to the normal user-target path (sendToNick + 401), exactly like a
|
|
5288
|
+
// direct PRIVMSG NickServ. The shortcut must NOT produce a bare 421.
|
|
5289
|
+
const { actor: shortcutActor, runtime: shortcutRuntime } = makeActor({});
|
|
5290
|
+
shortcutRuntime.calls.length = 0;
|
|
5291
|
+
await shortcutActor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5292
|
+
|
|
5293
|
+
const shortcutToNick = shortcutRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5294
|
+
expect(shortcutToNick).toHaveLength(1);
|
|
5295
|
+
const shortcutNotFound = shortcutToNick[0]?.args[3] as RawLine[] | undefined;
|
|
5296
|
+
expect(shortcutNotFound?.[0]?.text).toContain('401');
|
|
5297
|
+
|
|
5298
|
+
// Equivalence: a direct PRIVMSG NickServ with no store produces the same
|
|
5299
|
+
// 401 path, confirming the shortcut delegates identically.
|
|
5300
|
+
const { actor: directActor, runtime: directRuntime } = makeActor({});
|
|
5301
|
+
directRuntime.calls.length = 0;
|
|
5302
|
+
await directActor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5303
|
+
const directToNick = directRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5304
|
+
expect(directToNick).toHaveLength(1);
|
|
5305
|
+
|
|
5306
|
+
// Critical: neither path emits a bare 421 for the shortcut verb.
|
|
5307
|
+
const allShortcutText = shortcutRuntime.calls
|
|
5308
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5309
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5310
|
+
for (const t of allShortcutText) {
|
|
5311
|
+
expect(t).not.toContain(' 421 ');
|
|
5312
|
+
}
|
|
5313
|
+
});
|
|
5314
|
+
});
|
|
@@ -263,12 +263,13 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
async feed(chunk: string): Promise<void> {
|
|
266
|
-
/* istanbul ignore
|
|
266
|
+
/* istanbul ignore start -- defensive: bindActor is called by the
|
|
267
267
|
harness before the client is returned, so the unbound-actor branch
|
|
268
268
|
is unreachable through the public API. */
|
|
269
269
|
if (this.actor === undefined) {
|
|
270
270
|
throw new Error(`client ${this.id} has no bound actor`);
|
|
271
271
|
}
|
|
272
|
+
/* istanbul ignore stop */
|
|
272
273
|
await this.actor.receive(chunk);
|
|
273
274
|
}
|
|
274
275
|
|
|
@@ -305,16 +306,18 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
305
306
|
this.closed = true;
|
|
306
307
|
// Best-effort QUIT so peers see the part broadcast; the runtime's
|
|
307
308
|
// disconnect handler is wired by the harness to unregister.
|
|
309
|
+
/* istanbul ignore start -- defensive: spawnClient binds the actor
|
|
310
|
+
before returning the client, so `this.actor` is never undefined
|
|
311
|
+
here. The guard exists purely as a teardown safety net. */
|
|
308
312
|
if (this.actor !== undefined) {
|
|
309
313
|
try {
|
|
310
314
|
await this.actor.receive('QUIT :client closed\r\n');
|
|
311
|
-
/* istanbul ignore next -- defensive: receive swallows
|
|
312
|
-
every error internally; the catch is purely a teardown safety
|
|
313
|
-
net for catastrophic runtime failures. */
|
|
314
315
|
} catch {
|
|
315
|
-
// no-op
|
|
316
|
+
// no-op -- receive swallows every error internally; this catch is
|
|
317
|
+
// purely a teardown safety net for catastrophic runtime failures.
|
|
316
318
|
}
|
|
317
319
|
}
|
|
320
|
+
/* istanbul ignore stop */
|
|
318
321
|
}
|
|
319
322
|
}
|
|
320
323
|
|
|
@@ -182,7 +182,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
182
182
|
await alice.waitForNumeric('366');
|
|
183
183
|
const namesLine = alice.received.find((l) => numericOf(l) === '353');
|
|
184
184
|
expect(namesLine).toBeDefined();
|
|
185
|
-
|
|
185
|
+
// namesLine is still `string | undefined` to TS (expect() does not
|
|
186
|
+
// narrow), so we route it back through expect() rather than a
|
|
187
|
+
// non-null assertion — toContain fails loudly if it were undefined.
|
|
188
|
+
expect(namesLine).toContain('@alice');
|
|
186
189
|
await harness.close();
|
|
187
190
|
});
|
|
188
191
|
|
|
@@ -246,7 +249,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
246
249
|
await alice.send('JOIN #room');
|
|
247
250
|
await bob.send('JOIN #room');
|
|
248
251
|
await untilAliceSeesBobJoin(alice, '#room');
|
|
249
|
-
alice.received.
|
|
252
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
253
|
+
// the registration + JOIN burst makes the no-self-echo assertion
|
|
254
|
+
// below non-vacuous: it scans real traffic and confirms the
|
|
255
|
+
// sender never receives her own PRIVMSG among any of it.
|
|
250
256
|
bob.received.length = 0;
|
|
251
257
|
await alice.send('PRIVMSG #room :hello team');
|
|
252
258
|
await bob.waitForLine((l) => l.includes('PRIVMSG #room :hello team'));
|
|
@@ -453,7 +459,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
453
459
|
await alice.send('JOIN #notice');
|
|
454
460
|
await bob.send('JOIN #notice');
|
|
455
461
|
await untilAliceSeesBobJoin(alice, '#notice');
|
|
456
|
-
alice.received.
|
|
462
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
463
|
+
// the registration + JOIN burst makes the NOTICE-no-error filter
|
|
464
|
+
// below non-vacuous: it scans real numeric traffic (353/366/…)
|
|
465
|
+
// and confirms none of it is a NOTICE error code.
|
|
457
466
|
bob.received.length = 0;
|
|
458
467
|
await alice.send('NOTICE #notice :quiet note');
|
|
459
468
|
await bob.waitForLine((l) => l.includes('NOTICE #notice :quiet note'));
|
|
@@ -469,13 +478,19 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
469
478
|
await harness.close();
|
|
470
479
|
});
|
|
471
480
|
|
|
472
|
-
it('scenario 27: PASS after registration is
|
|
481
|
+
it('scenario 27: bare PASS after registration is a silent no-op (no 462)', async () => {
|
|
473
482
|
harness = await factory.create();
|
|
474
483
|
const c = await harness.spawnClient({ nick: 'alice' });
|
|
475
484
|
await c.waitForNumeric('001');
|
|
476
485
|
c.received.length = 0;
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
// A bare server-password PASS is meaningless once registration has
|
|
487
|
+
// completed: it must be ignored (no 462, no disconnect). Send a
|
|
488
|
+
// PING in the same frame so ordering is guaranteed — by the time
|
|
489
|
+
// the PONG arrives, any 462 from the PASS would already be queued.
|
|
490
|
+
await c.send('PASS hunter2\r\nPING :post-pass');
|
|
491
|
+
await c.waitForLine((l) => l === 'PONG :post-pass');
|
|
492
|
+
const errAlreadyRegistered = c.received.filter((l) => numericOf(l) === '462');
|
|
493
|
+
expect(errAlreadyRegistered).toEqual([]);
|
|
479
494
|
await harness.close();
|
|
480
495
|
});
|
|
481
496
|
|