serverless-ircd 0.7.0 → 0.9.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- 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-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- 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/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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 +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -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 +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- 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/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 +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
InMemoryMessageStore,
|
|
17
17
|
InMemoryMonitorStore,
|
|
18
18
|
InMemoryNickHistoryStore,
|
|
19
|
-
|
|
19
|
+
InMemoryServicesStore,
|
|
20
20
|
LogLevel,
|
|
21
21
|
type Logger,
|
|
22
22
|
type MessageStore,
|
|
@@ -26,18 +26,19 @@ import {
|
|
|
26
26
|
type NickHistoryStore,
|
|
27
27
|
Numerics,
|
|
28
28
|
type RawLine,
|
|
29
|
-
type ReadMarkerStore,
|
|
30
29
|
type SaslPayload,
|
|
31
30
|
type SaslResult,
|
|
32
31
|
SequentialIdFactory,
|
|
33
32
|
type ServerConfig,
|
|
34
33
|
type ServerStats,
|
|
35
34
|
type ServerStatsSnapshot,
|
|
35
|
+
type ServicesStore,
|
|
36
36
|
type StoredMessage,
|
|
37
37
|
applyChannelDelta as applyDelta,
|
|
38
38
|
createChannel,
|
|
39
39
|
createConnection,
|
|
40
40
|
encodeBase64,
|
|
41
|
+
formatServerTime,
|
|
41
42
|
isChannelTarget,
|
|
42
43
|
toChannelSnapshot,
|
|
43
44
|
toSnapshot,
|
|
@@ -228,7 +229,7 @@ function makeActor(opts: {
|
|
|
228
229
|
secure?: boolean;
|
|
229
230
|
monitor?: MonitorStore;
|
|
230
231
|
away?: AwayStore;
|
|
231
|
-
|
|
232
|
+
services?: ServicesStore;
|
|
232
233
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
233
234
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
234
235
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -306,8 +307,8 @@ function makeActor(opts: {
|
|
|
306
307
|
if (opts.away !== undefined) {
|
|
307
308
|
finalOpts.away = opts.away;
|
|
308
309
|
}
|
|
309
|
-
if (opts.
|
|
310
|
-
finalOpts.
|
|
310
|
+
if (opts.services !== undefined) {
|
|
311
|
+
finalOpts.services = opts.services;
|
|
311
312
|
}
|
|
312
313
|
const actor = new ConnectionActor(finalOpts);
|
|
313
314
|
return { actor, runtime, conn };
|
|
@@ -907,12 +908,13 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
907
908
|
expect(lines[0]?.text).toContain('462');
|
|
908
909
|
});
|
|
909
910
|
|
|
910
|
-
it('routes PASS to passReducer (
|
|
911
|
+
it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
|
|
911
912
|
const { actor, runtime } = makeActor({});
|
|
912
913
|
await actor.receiveTextFrame('PASS sekrat\r\n');
|
|
913
|
-
// passReducer on
|
|
914
|
+
// passReducer on a registered conn with no AccountStore treats a bare
|
|
915
|
+
// server-password PASS as a silent no-op (no 462, no disconnect).
|
|
914
916
|
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
915
|
-
expect(sends).toHaveLength(
|
|
917
|
+
expect(sends).toHaveLength(0);
|
|
916
918
|
});
|
|
917
919
|
|
|
918
920
|
it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
|
|
@@ -1229,9 +1231,13 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1229
1231
|
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1230
1232
|
|
|
1231
1233
|
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
expect(
|
|
1234
|
+
// OPER success emits two Send effects: the 381 numeric, then a
|
|
1235
|
+
// `:<hostmask> MODE <nick> +o` echo (mirrors the user MODE echo).
|
|
1236
|
+
expect(sends).toHaveLength(2);
|
|
1237
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1238
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
1239
|
+
const secondLines = sends[1]?.args[1] as RawLine[];
|
|
1240
|
+
expect(secondLines[0]?.text).toBe(':alice!alice@example.com MODE alice +o');
|
|
1235
1241
|
expect(conn.userModes.oper).toBe(true);
|
|
1236
1242
|
// Critical: never 421 for OPER.
|
|
1237
1243
|
for (const s of sends) {
|
|
@@ -1307,6 +1313,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1307
1313
|
wallops: false,
|
|
1308
1314
|
serverNotices: false,
|
|
1309
1315
|
tls: false,
|
|
1316
|
+
registered: false,
|
|
1310
1317
|
},
|
|
1311
1318
|
},
|
|
1312
1319
|
});
|
|
@@ -1359,6 +1366,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1359
1366
|
wallops: false,
|
|
1360
1367
|
serverNotices: false,
|
|
1361
1368
|
tls: false,
|
|
1369
|
+
registered: false,
|
|
1362
1370
|
},
|
|
1363
1371
|
},
|
|
1364
1372
|
});
|
|
@@ -1387,6 +1395,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1387
1395
|
wallops: false,
|
|
1388
1396
|
serverNotices: false,
|
|
1389
1397
|
tls: false,
|
|
1398
|
+
registered: false,
|
|
1390
1399
|
},
|
|
1391
1400
|
},
|
|
1392
1401
|
configSource: 'KV',
|
|
@@ -1456,6 +1465,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1456
1465
|
wallops: false,
|
|
1457
1466
|
serverNotices: false,
|
|
1458
1467
|
tls: false,
|
|
1468
|
+
registered: false,
|
|
1459
1469
|
},
|
|
1460
1470
|
},
|
|
1461
1471
|
configSource: 'KV',
|
|
@@ -1495,6 +1505,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1495
1505
|
wallops: false,
|
|
1496
1506
|
serverNotices: false,
|
|
1497
1507
|
tls: false,
|
|
1508
|
+
registered: false,
|
|
1498
1509
|
},
|
|
1499
1510
|
},
|
|
1500
1511
|
configSource: 'KV',
|
|
@@ -1528,6 +1539,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1528
1539
|
wallops: false,
|
|
1529
1540
|
serverNotices: false,
|
|
1530
1541
|
tls: false,
|
|
1542
|
+
registered: false,
|
|
1531
1543
|
},
|
|
1532
1544
|
},
|
|
1533
1545
|
configSource: 'KV',
|
|
@@ -4020,9 +4032,9 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
|
|
|
4020
4032
|
expect(away.get('alice')).toBeUndefined();
|
|
4021
4033
|
});
|
|
4022
4034
|
|
|
4023
|
-
it('persists a +draft/read-marker TAGMSG to the bound
|
|
4024
|
-
const
|
|
4025
|
-
const { actor, runtime, conn } = makeActor({
|
|
4035
|
+
it('persists a +draft/read-marker TAGMSG to the bound ServicesStore for an identified member', async () => {
|
|
4036
|
+
const services = new InMemoryServicesStore();
|
|
4037
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4026
4038
|
conn.account = 'alice';
|
|
4027
4039
|
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4028
4040
|
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
|
|
@@ -4030,7 +4042,258 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
|
|
|
4030
4042
|
|
|
4031
4043
|
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4032
4044
|
|
|
4033
|
-
expect(
|
|
4045
|
+
expect(services.getLastReadMarker('alice', '#foo')).toBe('msg-42');
|
|
4046
|
+
});
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
// ---------------------------------------------------------------------------
|
|
4050
|
+
// draft/read-marker — account-scoped fanout (spec compliance)
|
|
4051
|
+
//
|
|
4052
|
+
// The ircv3 draft/read-marker spec mandates that a read marker is private to
|
|
4053
|
+
// the originator's account: the mark TAGMSG is delivered only to the
|
|
4054
|
+
// originator's own connections, never to other accounts in the channel. The
|
|
4055
|
+
// reducer annotates the Broadcast with the originator account; dispatch
|
|
4056
|
+
// restricts the recipient set accordingly. These end-to-end tests drive the
|
|
4057
|
+
// full actor → reducer → dispatch → FakeRuntime path.
|
|
4058
|
+
// ---------------------------------------------------------------------------
|
|
4059
|
+
|
|
4060
|
+
describe('ConnectionActor — draft/read-marker account-scoped fanout', () => {
|
|
4061
|
+
function addIdentifiedMember(
|
|
4062
|
+
runtime: FakeRuntime,
|
|
4063
|
+
connId: ConnId,
|
|
4064
|
+
nick: string,
|
|
4065
|
+
account: string | undefined,
|
|
4066
|
+
caps: ReadonlyArray<string>,
|
|
4067
|
+
): ConnectionState {
|
|
4068
|
+
const state = createConnection({ id: connId, connectedSince: 0 });
|
|
4069
|
+
state.nick = nick;
|
|
4070
|
+
state.user = nick;
|
|
4071
|
+
state.host = `${nick}.example`;
|
|
4072
|
+
state.realname = nick;
|
|
4073
|
+
state.registration = 'registered';
|
|
4074
|
+
state.caps = new Set<string>(caps);
|
|
4075
|
+
if (account !== undefined) state.account = account;
|
|
4076
|
+
runtime.addConnection(state);
|
|
4077
|
+
return state;
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
async function seedChannel(
|
|
4081
|
+
runtime: FakeRuntime,
|
|
4082
|
+
members: ReadonlyArray<{ conn: ConnId; nick: string }>,
|
|
4083
|
+
): Promise<void> {
|
|
4084
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4085
|
+
memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
it('delivers the mark ONLY to same-account members; other accounts never receive it', async () => {
|
|
4090
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4091
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4092
|
+
conn.account = 'alice';
|
|
4093
|
+
// c2 = alice's other session (same account); c3 = bob (different account).
|
|
4094
|
+
addIdentifiedMember(runtime, 'c2', 'alice2', 'alice', ['message-tags', 'draft/read-marker']);
|
|
4095
|
+
addIdentifiedMember(runtime, 'c3', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4096
|
+
await seedChannel(runtime, [
|
|
4097
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4098
|
+
{ conn: 'c2', nick: 'alice2' },
|
|
4099
|
+
{ conn: 'c3', nick: 'bob' },
|
|
4100
|
+
]);
|
|
4101
|
+
runtime.calls.length = 0;
|
|
4102
|
+
|
|
4103
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4104
|
+
|
|
4105
|
+
// alice's other session (c2) receives the mark...
|
|
4106
|
+
expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-42'))).toBe(
|
|
4107
|
+
true,
|
|
4108
|
+
);
|
|
4109
|
+
// ...bob (c3, different account) never does.
|
|
4110
|
+
expect(sentTo(runtime, 'c3')).toEqual([]);
|
|
4111
|
+
});
|
|
4112
|
+
|
|
4113
|
+
it("delivers the mark to the originator's other session on the same account (multi-device sync)", async () => {
|
|
4114
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4115
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4116
|
+
conn.account = 'alice';
|
|
4117
|
+
addIdentifiedMember(runtime, 'c2', 'alice-laptop', 'alice', [
|
|
4118
|
+
'message-tags',
|
|
4119
|
+
'draft/read-marker',
|
|
4120
|
+
]);
|
|
4121
|
+
await seedChannel(runtime, [
|
|
4122
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4123
|
+
{ conn: 'c2', nick: 'alice-laptop' },
|
|
4124
|
+
]);
|
|
4125
|
+
runtime.calls.length = 0;
|
|
4126
|
+
|
|
4127
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-99 TAGMSG #foo\r\n');
|
|
4128
|
+
|
|
4129
|
+
expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-99'))).toBe(
|
|
4130
|
+
true,
|
|
4131
|
+
);
|
|
4132
|
+
});
|
|
4133
|
+
|
|
4134
|
+
it('does NOT fan out a mark from an unidentified originator to anyone', async () => {
|
|
4135
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4136
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4137
|
+
// originator unidentified (no account).
|
|
4138
|
+
addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4139
|
+
addIdentifiedMember(runtime, 'c3', 'carol', undefined, ['message-tags', 'draft/read-marker']);
|
|
4140
|
+
await seedChannel(runtime, [
|
|
4141
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4142
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4143
|
+
{ conn: 'c3', nick: 'carol' },
|
|
4144
|
+
]);
|
|
4145
|
+
runtime.calls.length = 0;
|
|
4146
|
+
|
|
4147
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
|
|
4148
|
+
|
|
4149
|
+
// No recipient receives the mark (session-local only).
|
|
4150
|
+
expect(sentTo(runtime, 'c2')).toEqual([]);
|
|
4151
|
+
expect(sentTo(runtime, 'c3')).toEqual([]);
|
|
4152
|
+
});
|
|
4153
|
+
|
|
4154
|
+
it('self-echoes the mark to an unidentified originator only when echo-message is negotiated', async () => {
|
|
4155
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4156
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker', 'echo-message']);
|
|
4157
|
+
// originator unidentified (no account).
|
|
4158
|
+
addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4159
|
+
await seedChannel(runtime, [
|
|
4160
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4161
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4162
|
+
]);
|
|
4163
|
+
runtime.calls.length = 0;
|
|
4164
|
+
|
|
4165
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
|
|
4166
|
+
|
|
4167
|
+
// No fanout to bob...
|
|
4168
|
+
expect(sentTo(runtime, 'c2')).toEqual([]);
|
|
4169
|
+
// ...but the originator sees its own mark reflected back via echo-message.
|
|
4170
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('+draft/read-marker=msg-1'))).toBe(
|
|
4171
|
+
true,
|
|
4172
|
+
);
|
|
4173
|
+
});
|
|
4174
|
+
|
|
4175
|
+
it('keeps the +draft/read-marker tag for a draft/read-marker-only same-account peer via the filterClientTags whitelist', async () => {
|
|
4176
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4177
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4178
|
+
conn.account = 'alice';
|
|
4179
|
+
// c2 announced ONLY draft/read-marker (not message-tags). The whitelist
|
|
4180
|
+
// in filterClientTags keeps the +draft/read-marker client tag for it.
|
|
4181
|
+
addIdentifiedMember(runtime, 'c2', 'alice-min', 'alice', ['draft/read-marker']);
|
|
4182
|
+
await seedChannel(runtime, [
|
|
4183
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4184
|
+
{ conn: 'c2', nick: 'alice-min' },
|
|
4185
|
+
]);
|
|
4186
|
+
runtime.calls.length = 0;
|
|
4187
|
+
|
|
4188
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-5 TAGMSG #foo\r\n');
|
|
4189
|
+
|
|
4190
|
+
const lines = sentTo(runtime, 'c2');
|
|
4191
|
+
expect(lines).toHaveLength(1);
|
|
4192
|
+
// The tag survives the whitelist for a draft/read-marker-only peer.
|
|
4193
|
+
expect(lines[0]?.text).toBe('@+draft/read-marker=msg-5 :alice!alice@example.com TAGMSG #foo');
|
|
4194
|
+
});
|
|
4195
|
+
});
|
|
4196
|
+
|
|
4197
|
+
// ---------------------------------------------------------------------------
|
|
4198
|
+
// draft/read-marker — MARKREAD verb (draft-spec client compatibility)
|
|
4199
|
+
//
|
|
4200
|
+
// The deployment stores read markers by msgid but accepts the draft's
|
|
4201
|
+
// timestamped MARKREAD verb from cap-enabled clients, translating the
|
|
4202
|
+
// timestamp to the most recent retained message at or before it. These
|
|
4203
|
+
// end-to-end tests drive the full actor -> reducer path and assert the verb
|
|
4204
|
+
// is routed (never 421 for a cap-enabled client).
|
|
4205
|
+
// ---------------------------------------------------------------------------
|
|
4206
|
+
|
|
4207
|
+
describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
|
|
4208
|
+
const TS = (ms: number): string => `timestamp=${formatServerTime(ms)}`;
|
|
4209
|
+
|
|
4210
|
+
/** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
|
|
4211
|
+
function seedMessages(store: MessageStore, chan: string, count: number): void {
|
|
4212
|
+
for (let i = 1; i <= count; i++) {
|
|
4213
|
+
const m: StoredMessage = {
|
|
4214
|
+
msgid: `m${i}`,
|
|
4215
|
+
time: i * 1_000,
|
|
4216
|
+
chan: chan.toLowerCase(),
|
|
4217
|
+
command: 'PRIVMSG',
|
|
4218
|
+
nick: 'bob',
|
|
4219
|
+
user: 'bob',
|
|
4220
|
+
host: 'ex.org',
|
|
4221
|
+
text: `msg ${i}`,
|
|
4222
|
+
};
|
|
4223
|
+
store.record(m);
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
it('routes MARKREAD to the reducer (never 421) and echoes the resolved timestamp', async () => {
|
|
4228
|
+
const store = new InMemoryMessageStore();
|
|
4229
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4230
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4231
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4232
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
4233
|
+
});
|
|
4234
|
+
seedMessages(store, '#foo', 3); // m1=1s, m2=2s, m3=3s
|
|
4235
|
+
runtime.calls.length = 0;
|
|
4236
|
+
|
|
4237
|
+
await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
|
|
4238
|
+
|
|
4239
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4240
|
+
expect(sends).toHaveLength(1);
|
|
4241
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4242
|
+
expect(lines[0]?.text).toBe(`:irc.example.com MARKREAD #foo ${TS(2_000)}`);
|
|
4243
|
+
// Critical: never 421 for a cap-enabled MARKREAD.
|
|
4244
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
4245
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
|
|
4246
|
+
});
|
|
4247
|
+
|
|
4248
|
+
it('still emits 421 for MARKREAD when the cap is not negotiated (reducer cap-gating)', async () => {
|
|
4249
|
+
const store = new InMemoryMessageStore();
|
|
4250
|
+
const { actor, runtime } = makeActor({ messages: store });
|
|
4251
|
+
// No draft/read-marker cap.
|
|
4252
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4253
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
4254
|
+
});
|
|
4255
|
+
seedMessages(store, '#foo', 3);
|
|
4256
|
+
runtime.calls.length = 0;
|
|
4257
|
+
|
|
4258
|
+
await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
|
|
4259
|
+
|
|
4260
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4261
|
+
expect(sends).toHaveLength(1);
|
|
4262
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4263
|
+
expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
it('silently ignores a nick-target MARKREAD (read markers are channel-scoped)', async () => {
|
|
4267
|
+
const store = new InMemoryMessageStore();
|
|
4268
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4269
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4270
|
+
runtime.calls.length = 0;
|
|
4271
|
+
|
|
4272
|
+
// A nick (non-channel) target falls through to the `return []` arm — the
|
|
4273
|
+
// deployment has no nick-scoped read markers, so the verb is a no-op
|
|
4274
|
+
// rather than a 421 or a FAIL.
|
|
4275
|
+
await actor.receiveTextFrame(`MARKREAD alice ${TS(2_000)}\r\n`);
|
|
4276
|
+
|
|
4277
|
+
expect(runtime.calls).toHaveLength(0);
|
|
4278
|
+
});
|
|
4279
|
+
|
|
4280
|
+
it('routes a parameterless MARKREAD through the reducer (NEED_MORE_PARAMS, never 421 fallthrough)', async () => {
|
|
4281
|
+
const store = new InMemoryMessageStore();
|
|
4282
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4283
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4284
|
+
runtime.calls.length = 0;
|
|
4285
|
+
|
|
4286
|
+
// No params at all → `msg.params[0] ?? ''` falls back to the empty
|
|
4287
|
+
// string, which is routed into the reducer so it can FAIL
|
|
4288
|
+
// NEED_MORE_PARAMS rather than hitting the generic 421 fallthrough.
|
|
4289
|
+
await actor.receiveTextFrame('MARKREAD\r\n');
|
|
4290
|
+
|
|
4291
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4292
|
+
expect(sends).toHaveLength(1);
|
|
4293
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4294
|
+
expect(lines[0]?.text).toContain('NEED_MORE_PARAMS');
|
|
4295
|
+
// Critical: never the generic 421 fallthrough for a cap-enabled client.
|
|
4296
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
4034
4297
|
});
|
|
4035
4298
|
});
|
|
4036
4299
|
|
|
@@ -4367,3 +4630,713 @@ describe('ConnectionActor — draft/multiline chathistory recording', () => {
|
|
|
4367
4630
|
expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
|
|
4368
4631
|
});
|
|
4369
4632
|
});
|
|
4633
|
+
|
|
4634
|
+
// ============================================================================
|
|
4635
|
+
// NickServ routing — PRIVMSG NickServ :<command>
|
|
4636
|
+
// ============================================================================
|
|
4637
|
+
|
|
4638
|
+
describe('ConnectionActor — NickServ routing', () => {
|
|
4639
|
+
it('routes PRIVMSG NickServ :IDENTIFY to the NickServ reducer and sets +r', async () => {
|
|
4640
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4641
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4642
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4643
|
+
runtime.calls.length = 0;
|
|
4644
|
+
|
|
4645
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4646
|
+
|
|
4647
|
+
expect(conn.account).toBe('alice');
|
|
4648
|
+
expect(conn.userModes.registered).toBe(true);
|
|
4649
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4650
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4651
|
+
expect(allText).toContain(
|
|
4652
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
4653
|
+
);
|
|
4654
|
+
});
|
|
4655
|
+
|
|
4656
|
+
it('routes PRIVMSG NickServ :REGISTER and confirms via NOTICE', async () => {
|
|
4657
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4658
|
+
const { actor, runtime } = makeActor({ services });
|
|
4659
|
+
runtime.calls.length = 0;
|
|
4660
|
+
|
|
4661
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :REGISTER hunter2 alice@example.com\r\n');
|
|
4662
|
+
|
|
4663
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
4664
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4665
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4666
|
+
expect(allText).toContain(
|
|
4667
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
|
|
4668
|
+
);
|
|
4669
|
+
});
|
|
4670
|
+
|
|
4671
|
+
it('does NOT route NOTICE NickServ to the NickServ reducer (RFC: NOTICE is silent)', async () => {
|
|
4672
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4673
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4674
|
+
runtime.calls.length = 0;
|
|
4675
|
+
|
|
4676
|
+
await actor.receiveTextFrame('NOTICE NickServ :IDENTIFY hunter2\r\n');
|
|
4677
|
+
|
|
4678
|
+
// NOTICE to NickServ falls through to the normal user-target path (no
|
|
4679
|
+
// NickServ NOTICE emitted, no state change).
|
|
4680
|
+
expect(conn.account).toBeUndefined();
|
|
4681
|
+
const nickservNotices = runtime.calls.filter(
|
|
4682
|
+
(c) =>
|
|
4683
|
+
c.method === 'send' &&
|
|
4684
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('NickServ!NickServ@services')),
|
|
4685
|
+
);
|
|
4686
|
+
expect(nickservNotices).toHaveLength(0);
|
|
4687
|
+
});
|
|
4688
|
+
|
|
4689
|
+
it('treats PRIVMSG NickServ as a normal nick-target when no services store is bound', async () => {
|
|
4690
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4691
|
+
runtime.calls.length = 0;
|
|
4692
|
+
|
|
4693
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4694
|
+
|
|
4695
|
+
// Without a services store, NickServ is just a nick target → the normal
|
|
4696
|
+
// user-target reducer emits a SendToNick with a 401 fallback.
|
|
4697
|
+
expect(conn.account).toBeUndefined();
|
|
4698
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4699
|
+
expect(toNick).toHaveLength(1);
|
|
4700
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4701
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4702
|
+
});
|
|
4703
|
+
|
|
4704
|
+
it('routes a case-insensitive PRIVMSG nickserv :… to the NickServ reducer', async () => {
|
|
4705
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4706
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4707
|
+
const { actor, conn } = makeActor({ services });
|
|
4708
|
+
|
|
4709
|
+
await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
|
|
4710
|
+
|
|
4711
|
+
expect(conn.account).toBe('alice');
|
|
4712
|
+
});
|
|
4713
|
+
});
|
|
4714
|
+
|
|
4715
|
+
// ============================================================================
|
|
4716
|
+
// ChanServ routing — PRIVMSG ChanServ :<command>
|
|
4717
|
+
// ============================================================================
|
|
4718
|
+
|
|
4719
|
+
describe('ConnectionActor — ChanServ routing', () => {
|
|
4720
|
+
it('routes PRIVMSG ChanServ :REGISTER and applies the +r ApplyChannelDelta', async () => {
|
|
4721
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4722
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4723
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4724
|
+
conn.account = 'alice';
|
|
4725
|
+
conn.userModes.registered = true;
|
|
4726
|
+
runtime.calls.length = 0;
|
|
4727
|
+
|
|
4728
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
|
|
4729
|
+
|
|
4730
|
+
expect(services.getChannelFounder('#home')).toBe('alice');
|
|
4731
|
+
const applyDeltas = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
|
|
4732
|
+
expect(applyDeltas.length).toBeGreaterThan(0);
|
|
4733
|
+
});
|
|
4734
|
+
|
|
4735
|
+
it('routes PRIVMSG ChanServ :SET KEEPTOPIC #home ON and persists', async () => {
|
|
4736
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4737
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4738
|
+
services.registerChannel('#home', 'alice');
|
|
4739
|
+
const { actor, conn } = makeActor({ services });
|
|
4740
|
+
conn.account = 'alice';
|
|
4741
|
+
conn.userModes.registered = true;
|
|
4742
|
+
|
|
4743
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :SET KEEPTOPIC #home ON\r\n');
|
|
4744
|
+
|
|
4745
|
+
expect(services.getChannel('#home')?.keepTopic).toBe(true);
|
|
4746
|
+
});
|
|
4747
|
+
|
|
4748
|
+
it('does NOT route NOTICE ChanServ to the ChanServ reducer', async () => {
|
|
4749
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4750
|
+
const { actor, runtime } = makeActor({ services });
|
|
4751
|
+
runtime.calls.length = 0;
|
|
4752
|
+
|
|
4753
|
+
await actor.receiveTextFrame('NOTICE ChanServ :REGISTER #home\r\n');
|
|
4754
|
+
|
|
4755
|
+
const chanservNotices = runtime.calls.filter(
|
|
4756
|
+
(c) =>
|
|
4757
|
+
c.method === 'send' &&
|
|
4758
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('ChanServ!ChanServ@services')),
|
|
4759
|
+
);
|
|
4760
|
+
expect(chanservNotices).toHaveLength(0);
|
|
4761
|
+
});
|
|
4762
|
+
|
|
4763
|
+
it('treats PRIVMSG ChanServ as a normal nick-target when no services store is bound', async () => {
|
|
4764
|
+
const { actor, runtime } = makeActor({});
|
|
4765
|
+
runtime.calls.length = 0;
|
|
4766
|
+
|
|
4767
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
|
|
4768
|
+
|
|
4769
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4770
|
+
expect(toNick).toHaveLength(1);
|
|
4771
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4772
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4773
|
+
});
|
|
4774
|
+
|
|
4775
|
+
it('routes a case-insensitive PRIVMSG chanserv :… to the ChanServ reducer', async () => {
|
|
4776
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4777
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4778
|
+
const { actor, conn } = makeActor({ services });
|
|
4779
|
+
conn.account = 'alice';
|
|
4780
|
+
conn.userModes.registered = true;
|
|
4781
|
+
|
|
4782
|
+
await actor.receiveTextFrame('PRIVMSG chanserv :REGISTER #home\r\n');
|
|
4783
|
+
|
|
4784
|
+
expect(services.getChannelFounder('#home')).toBe('alice');
|
|
4785
|
+
});
|
|
4786
|
+
});
|
|
4787
|
+
|
|
4788
|
+
// ============================================================================
|
|
4789
|
+
// MemoServ routing — PRIVMSG MemoServ :<command>
|
|
4790
|
+
// ============================================================================
|
|
4791
|
+
|
|
4792
|
+
describe('ConnectionActor — MemoServ routing', () => {
|
|
4793
|
+
it('routes PRIVMSG MemoServ :SEND and records the memo against the recipient', async () => {
|
|
4794
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4795
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4796
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
4797
|
+
const { actor, conn } = makeActor({ services });
|
|
4798
|
+
conn.account = 'alice';
|
|
4799
|
+
conn.userModes.registered = true;
|
|
4800
|
+
|
|
4801
|
+
await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi from actor\r\n');
|
|
4802
|
+
|
|
4803
|
+
const memos = services.listMemos('bob');
|
|
4804
|
+
expect(memos).toHaveLength(1);
|
|
4805
|
+
expect(memos[0]?.body).toBe('Hi from actor');
|
|
4806
|
+
});
|
|
4807
|
+
|
|
4808
|
+
it('delivers a queued memo via MemoServ NOTICE on NickServ IDENTIFY', async () => {
|
|
4809
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4810
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4811
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'queued', sentAt: 1_000 });
|
|
4812
|
+
const { actor, runtime } = makeActor({ services });
|
|
4813
|
+
runtime.calls.length = 0;
|
|
4814
|
+
|
|
4815
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4816
|
+
|
|
4817
|
+
const memoNotices = runtime.calls.filter(
|
|
4818
|
+
(c) =>
|
|
4819
|
+
c.method === 'send' &&
|
|
4820
|
+
(c.args[1] as RawLine[]).some((l) =>
|
|
4821
|
+
l.text.includes('MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] queued'),
|
|
4822
|
+
),
|
|
4823
|
+
);
|
|
4824
|
+
expect(memoNotices).toHaveLength(1);
|
|
4825
|
+
});
|
|
4826
|
+
|
|
4827
|
+
it('does NOT route NOTICE MemoServ to the MemoServ reducer', async () => {
|
|
4828
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4829
|
+
const { actor, runtime } = makeActor({ services });
|
|
4830
|
+
runtime.calls.length = 0;
|
|
4831
|
+
|
|
4832
|
+
await actor.receiveTextFrame('NOTICE MemoServ :SEND bob Hi\r\n');
|
|
4833
|
+
|
|
4834
|
+
const memoservNotices = runtime.calls.filter(
|
|
4835
|
+
(c) =>
|
|
4836
|
+
c.method === 'send' &&
|
|
4837
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('MemoServ!MemoServ@services')),
|
|
4838
|
+
);
|
|
4839
|
+
expect(memoservNotices).toHaveLength(0);
|
|
4840
|
+
});
|
|
4841
|
+
|
|
4842
|
+
it('treats PRIVMSG MemoServ as a normal nick-target when no services store is bound', async () => {
|
|
4843
|
+
const { actor, runtime } = makeActor({});
|
|
4844
|
+
runtime.calls.length = 0;
|
|
4845
|
+
|
|
4846
|
+
await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi\r\n');
|
|
4847
|
+
|
|
4848
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4849
|
+
expect(toNick).toHaveLength(1);
|
|
4850
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4851
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4852
|
+
});
|
|
4853
|
+
|
|
4854
|
+
it('routes a case-insensitive PRIVMSG memoserv :… to the MemoServ reducer', async () => {
|
|
4855
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4856
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4857
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
4858
|
+
const { actor, conn } = makeActor({ services });
|
|
4859
|
+
conn.account = 'alice';
|
|
4860
|
+
conn.userModes.registered = true;
|
|
4861
|
+
|
|
4862
|
+
await actor.receiveTextFrame('PRIVMSG memoserv :SEND bob lower-case\r\n');
|
|
4863
|
+
|
|
4864
|
+
expect(services.listMemos('bob')[0]?.body).toBe('lower-case');
|
|
4865
|
+
});
|
|
4866
|
+
});
|
|
4867
|
+
|
|
4868
|
+
// ============================================================================
|
|
4869
|
+
// OperServ routing — PRIVMSG OperServ :<command> (oper-gated management)
|
|
4870
|
+
// ============================================================================
|
|
4871
|
+
|
|
4872
|
+
describe('ConnectionActor — OperServ routing', () => {
|
|
4873
|
+
it('routes PRIVMSG OperServ :AKILL LIST from an oper to the OperServ reducer (not 401)', async () => {
|
|
4874
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4875
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4876
|
+
conn.userModes.oper = true;
|
|
4877
|
+
runtime.calls.length = 0;
|
|
4878
|
+
|
|
4879
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
|
|
4880
|
+
|
|
4881
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4882
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4883
|
+
expect(allText).toContain(
|
|
4884
|
+
':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
|
|
4885
|
+
);
|
|
4886
|
+
// Critical: never 401 for a routed OperServ PRIVMSG.
|
|
4887
|
+
for (const t of allText) {
|
|
4888
|
+
expect(t).not.toContain(' 401 ');
|
|
4889
|
+
}
|
|
4890
|
+
});
|
|
4891
|
+
|
|
4892
|
+
it('round-trips oper AKILL ADD / DEL via PRIVMSG OperServ', async () => {
|
|
4893
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4894
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4895
|
+
conn.userModes.oper = true;
|
|
4896
|
+
runtime.calls.length = 0;
|
|
4897
|
+
|
|
4898
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
|
|
4899
|
+
expect(services.listAkills()).toHaveLength(1);
|
|
4900
|
+
const addSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4901
|
+
const addText = addSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4902
|
+
expect(addText).toContain(
|
|
4903
|
+
':OperServ!OperServ@services NOTICE alice :AKILL *bad@host added (reason: flooding).',
|
|
4904
|
+
);
|
|
4905
|
+
|
|
4906
|
+
runtime.calls.length = 0;
|
|
4907
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL DEL *bad@host\r\n');
|
|
4908
|
+
expect(services.listAkills()).toHaveLength(0);
|
|
4909
|
+
const delSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4910
|
+
const delText = delSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4911
|
+
expect(delText).toContain(':OperServ!OperServ@services NOTICE alice :AKILL *bad@host removed.');
|
|
4912
|
+
});
|
|
4913
|
+
|
|
4914
|
+
it('round-trips oper JUPE / UNJUPE via PRIVMSG OperServ', async () => {
|
|
4915
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4916
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4917
|
+
conn.userModes.oper = true;
|
|
4918
|
+
runtime.calls.length = 0;
|
|
4919
|
+
|
|
4920
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :JUPE malkovich\r\n');
|
|
4921
|
+
expect(services.isJuped('malkovich')).toBe(true);
|
|
4922
|
+
const jupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4923
|
+
const jupeText = jupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4924
|
+
expect(jupeText).toContain(':OperServ!OperServ@services NOTICE alice :JUPE malkovich added.');
|
|
4925
|
+
|
|
4926
|
+
runtime.calls.length = 0;
|
|
4927
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :UNJUPE malkovich\r\n');
|
|
4928
|
+
expect(services.isJuped('malkovich')).toBe(false);
|
|
4929
|
+
const unjupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4930
|
+
const unjupeText = unjupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4931
|
+
expect(unjupeText).toContain(
|
|
4932
|
+
':OperServ!OperServ@services NOTICE alice :JUPE malkovich removed.',
|
|
4933
|
+
);
|
|
4934
|
+
});
|
|
4935
|
+
|
|
4936
|
+
it('round-trips oper RAW via PRIVMSG OperServ', async () => {
|
|
4937
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4938
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4939
|
+
conn.userModes.oper = true;
|
|
4940
|
+
runtime.calls.length = 0;
|
|
4941
|
+
|
|
4942
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :RAW :srv.example.com STATS u\r\n');
|
|
4943
|
+
|
|
4944
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4945
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4946
|
+
expect(allText).toContain(
|
|
4947
|
+
':OperServ!OperServ@services NOTICE alice :RAW line accepted and logged.',
|
|
4948
|
+
);
|
|
4949
|
+
});
|
|
4950
|
+
|
|
4951
|
+
it('rejects a non-oper PRIVMSG OperServ :AKILL ADD with permission-denied NOTICE (not 401, no state change)', async () => {
|
|
4952
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4953
|
+
const { actor, runtime } = makeActor({ services });
|
|
4954
|
+
// conn.userModes.oper left false
|
|
4955
|
+
runtime.calls.length = 0;
|
|
4956
|
+
|
|
4957
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
|
|
4958
|
+
|
|
4959
|
+
expect(services.listAkills()).toHaveLength(0);
|
|
4960
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4961
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4962
|
+
expect(allText).toContain(
|
|
4963
|
+
':OperServ!OperServ@services NOTICE alice :Permission denied — oper privileges required.',
|
|
4964
|
+
);
|
|
4965
|
+
for (const t of allText) {
|
|
4966
|
+
expect(t).not.toContain(' 401 ');
|
|
4967
|
+
}
|
|
4968
|
+
});
|
|
4969
|
+
|
|
4970
|
+
it('does NOT route NOTICE OperServ to the OperServ reducer (PRIVMSG-only interception)', async () => {
|
|
4971
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4972
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4973
|
+
conn.userModes.oper = true;
|
|
4974
|
+
runtime.calls.length = 0;
|
|
4975
|
+
|
|
4976
|
+
await actor.receiveTextFrame('NOTICE OperServ :AKILL LIST\r\n');
|
|
4977
|
+
|
|
4978
|
+
// NOTICE to OperServ falls through to the normal user-target path (no
|
|
4979
|
+
// OperServ NOTICE emitted, no state change).
|
|
4980
|
+
const operservNotices = runtime.calls.filter(
|
|
4981
|
+
(c) =>
|
|
4982
|
+
c.method === 'send' &&
|
|
4983
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('OperServ!OperServ@services')),
|
|
4984
|
+
);
|
|
4985
|
+
expect(operservNotices).toHaveLength(0);
|
|
4986
|
+
});
|
|
4987
|
+
|
|
4988
|
+
it('treats PRIVMSG OperServ as a normal nick-target when no services store is bound', async () => {
|
|
4989
|
+
const { actor, runtime } = makeActor({});
|
|
4990
|
+
runtime.calls.length = 0;
|
|
4991
|
+
|
|
4992
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
|
|
4993
|
+
|
|
4994
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4995
|
+
expect(toNick).toHaveLength(1);
|
|
4996
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4997
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4998
|
+
});
|
|
4999
|
+
|
|
5000
|
+
it('routes a case-insensitive PRIVMSG operserv :… to the OperServ reducer', async () => {
|
|
5001
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5002
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
5003
|
+
conn.userModes.oper = true;
|
|
5004
|
+
runtime.calls.length = 0;
|
|
5005
|
+
|
|
5006
|
+
await actor.receiveTextFrame('PRIVMSG operserv :AKILL LIST\r\n');
|
|
5007
|
+
|
|
5008
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5009
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5010
|
+
expect(allText).toContain(
|
|
5011
|
+
':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
|
|
5012
|
+
);
|
|
5013
|
+
});
|
|
5014
|
+
});
|
|
5015
|
+
|
|
5016
|
+
// ============================================================================
|
|
5017
|
+
// echo-message — services PRIVMSG echo (IRCv3)
|
|
5018
|
+
//
|
|
5019
|
+
// When a client that negotiated `echo-message` sends `PRIVMSG <ServiceNick>`
|
|
5020
|
+
// the server must echo the sender's own PRIVMSG line back, BEFORE the service
|
|
5021
|
+
// reducer's NOTICE reply — matching how every other PRIVMSG renders (your
|
|
5022
|
+
// outgoing line first, then any reply). Without this echo,
|
|
5023
|
+
// modern IRCv3 clients optimistically show the outgoing message as "sending"
|
|
5024
|
+
// and flip it to "failed" when no delivery confirmation arrives.
|
|
5025
|
+
// ============================================================================
|
|
5026
|
+
|
|
5027
|
+
describe('ConnectionActor — echo-message on service PRIVMSG', () => {
|
|
5028
|
+
function makeOnlineConn(id: string, nick: string): ConnectionState {
|
|
5029
|
+
const c = createConnection({ id, connectedSince: 0 });
|
|
5030
|
+
c.nick = nick;
|
|
5031
|
+
c.user = nick;
|
|
5032
|
+
c.host = `${nick}.org`;
|
|
5033
|
+
c.realname = nick;
|
|
5034
|
+
c.registration = 'registered';
|
|
5035
|
+
return c;
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
it('echoes the sender PRIVMSG back when echo-message is negotiated (NickServ)', async () => {
|
|
5039
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5040
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5041
|
+
const { actor, runtime } = makeActor({
|
|
5042
|
+
services,
|
|
5043
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5044
|
+
});
|
|
5045
|
+
runtime.calls.length = 0;
|
|
5046
|
+
|
|
5047
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5048
|
+
|
|
5049
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5050
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5051
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5052
|
+
});
|
|
5053
|
+
|
|
5054
|
+
it('does NOT echo when echo-message is not negotiated', async () => {
|
|
5055
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5056
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5057
|
+
const { actor, runtime } = makeActor({ services });
|
|
5058
|
+
runtime.calls.length = 0;
|
|
5059
|
+
|
|
5060
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5061
|
+
|
|
5062
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5063
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5064
|
+
for (const t of allText) {
|
|
5065
|
+
expect(t).not.toMatch(/PRIVMSG NickServ :IDENTIFY hunter2$/);
|
|
5066
|
+
}
|
|
5067
|
+
});
|
|
5068
|
+
|
|
5069
|
+
it('emits the echo BEFORE the NickServ NOTICE reply (ordering)', async () => {
|
|
5070
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5071
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5072
|
+
const { actor, runtime } = makeActor({
|
|
5073
|
+
services,
|
|
5074
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5075
|
+
});
|
|
5076
|
+
runtime.calls.length = 0;
|
|
5077
|
+
|
|
5078
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5079
|
+
|
|
5080
|
+
const sendCalls = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5081
|
+
const findIdx = (needle: string): number =>
|
|
5082
|
+
sendCalls.findIndex((c) => (c.args[1] as RawLine[]).some((l) => l.text.includes(needle)));
|
|
5083
|
+
const noticeIdx = findIdx('NickServ!NickServ@services NOTICE');
|
|
5084
|
+
const echoIdx = findIdx('PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5085
|
+
expect(noticeIdx).toBeGreaterThanOrEqual(0);
|
|
5086
|
+
expect(echoIdx).toBeGreaterThanOrEqual(0);
|
|
5087
|
+
expect(echoIdx).toBeLessThan(noticeIdx);
|
|
5088
|
+
});
|
|
5089
|
+
|
|
5090
|
+
it('echoes the sender PRIVMSG back for ChanServ (shared fix, not NickServ-specific)', async () => {
|
|
5091
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5092
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5093
|
+
const { actor, runtime } = makeActor({
|
|
5094
|
+
services,
|
|
5095
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5096
|
+
});
|
|
5097
|
+
runtime.calls.length = 0;
|
|
5098
|
+
|
|
5099
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :HELP\r\n');
|
|
5100
|
+
|
|
5101
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5102
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5103
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG ChanServ :HELP');
|
|
5104
|
+
});
|
|
5105
|
+
|
|
5106
|
+
it('preserves the sender target casing in the echo', async () => {
|
|
5107
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5108
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5109
|
+
const { actor, runtime } = makeActor({
|
|
5110
|
+
services,
|
|
5111
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5112
|
+
});
|
|
5113
|
+
runtime.calls.length = 0;
|
|
5114
|
+
|
|
5115
|
+
await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
|
|
5116
|
+
|
|
5117
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5118
|
+
const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5119
|
+
expect(allText).toContain(':alice!alice@example.com PRIVMSG nickserv :IDENTIFY hunter2');
|
|
5120
|
+
});
|
|
5121
|
+
|
|
5122
|
+
it('does NOT intercept PRIVMSG to a non-service nick when services are bound', async () => {
|
|
5123
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5124
|
+
const { actor, runtime } = makeActor({ services });
|
|
5125
|
+
runtime.addConnection(makeOnlineConn('c2', 'bob'));
|
|
5126
|
+
runtime.calls.length = 0;
|
|
5127
|
+
|
|
5128
|
+
await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
|
|
5129
|
+
|
|
5130
|
+
// With services bound, a non-service nick still routes through the normal
|
|
5131
|
+
// user-target path (sendToNick), never the service reducer or echo path.
|
|
5132
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
5133
|
+
expect(toNick).toHaveLength(1);
|
|
5134
|
+
});
|
|
5135
|
+
|
|
5136
|
+
it('echoes with empty text when the PRIVMSG carries no trailing parameter', async () => {
|
|
5137
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5138
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5139
|
+
const { actor, runtime } = makeActor({
|
|
5140
|
+
services,
|
|
5141
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5142
|
+
});
|
|
5143
|
+
runtime.calls.length = 0;
|
|
5144
|
+
|
|
5145
|
+
// No `:text` argument → `singleParamMsg.params[1] ?? ''` falls back to an
|
|
5146
|
+
// empty string in the echo line.
|
|
5147
|
+
await actor.receiveTextFrame('PRIVMSG NickServ\r\n');
|
|
5148
|
+
|
|
5149
|
+
const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5150
|
+
const echo = toAlice
|
|
5151
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5152
|
+
.find((t) => t.endsWith('PRIVMSG NickServ :'));
|
|
5153
|
+
expect(echo).toBe(':alice!alice@example.com PRIVMSG NickServ :');
|
|
5154
|
+
});
|
|
5155
|
+
|
|
5156
|
+
it('falls back to "?" hostmask in the echo when the sender has no nick', async () => {
|
|
5157
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5158
|
+
const { actor, runtime, conn } = makeActor({
|
|
5159
|
+
services,
|
|
5160
|
+
conn: { caps: new Set<string>(['echo-message']) },
|
|
5161
|
+
});
|
|
5162
|
+
// A connection that negotiated echo-message but has no NICK/USER/HOST —
|
|
5163
|
+
// exercises the `hostmaskOf(state) ?? state.nick ?? '?'` fallback chain
|
|
5164
|
+
// in the service-PRIVMSG echo. `delete` is required because
|
|
5165
|
+
// exactOptionalPropertyTypes forbids `= undefined`.
|
|
5166
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5167
|
+
delete conn.nick;
|
|
5168
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5169
|
+
delete conn.user;
|
|
5170
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
|
|
5171
|
+
delete conn.host;
|
|
5172
|
+
runtime.calls.length = 0;
|
|
5173
|
+
|
|
5174
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5175
|
+
|
|
5176
|
+
const toSender = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5177
|
+
const echo = toSender
|
|
5178
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
|
|
5179
|
+
.find((t) => t.includes('PRIVMSG NickServ :IDENTIFY hunter2'));
|
|
5180
|
+
// Both `??` arms fire: hostmaskOf returns undefined (no nick), then
|
|
5181
|
+
// state.nick is undefined, so the final '?' fallback is used.
|
|
5182
|
+
expect(echo).toBe(':? PRIVMSG NickServ :IDENTIFY hunter2');
|
|
5183
|
+
});
|
|
5184
|
+
});
|
|
5185
|
+
|
|
5186
|
+
// ============================================================================
|
|
5187
|
+
// Service shortcut commands — NICKSERV/NS/CHANSERV/CS/HOSTSERV/HS/MEMOSERV/MS/
|
|
5188
|
+
// OPERSERV/OS (Atheme/Anope-convention aliases rewritten to
|
|
5189
|
+
// PRIVMSG <ServiceNick> :<joined args> by the dispatcher).
|
|
5190
|
+
// ============================================================================
|
|
5191
|
+
|
|
5192
|
+
describe('ConnectionActor — service shortcut commands', () => {
|
|
5193
|
+
it('NICKSERV IDENTIFY behaves identically to PRIVMSG NickServ :IDENTIFY (never 421)', async () => {
|
|
5194
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5195
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5196
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
5197
|
+
runtime.calls.length = 0;
|
|
5198
|
+
|
|
5199
|
+
await actor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5200
|
+
|
|
5201
|
+
expect(conn.account).toBe('alice');
|
|
5202
|
+
expect(conn.userModes.registered).toBe(true);
|
|
5203
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5204
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5205
|
+
expect(allText).toContain(
|
|
5206
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
5207
|
+
);
|
|
5208
|
+
// Critical: the shortcut must reach the reducer, never the 421 fallthrough.
|
|
5209
|
+
for (const t of allText) {
|
|
5210
|
+
expect(t).not.toContain(' 421 ');
|
|
5211
|
+
}
|
|
5212
|
+
});
|
|
5213
|
+
|
|
5214
|
+
// Each alias must resolve to its service. Sending the bare alias (no args)
|
|
5215
|
+
// reaches the reducer, which emits a help NOTICE sourced from the matching
|
|
5216
|
+
// pseudo-client — so the NOTICE source nick identifies the routed service.
|
|
5217
|
+
const aliasToService: ReadonlyArray<[string, string]> = [
|
|
5218
|
+
['NICKSERV', 'NickServ'],
|
|
5219
|
+
['NS', 'NickServ'],
|
|
5220
|
+
['CHANSERV', 'ChanServ'],
|
|
5221
|
+
['CS', 'ChanServ'],
|
|
5222
|
+
['HOSTSERV', 'HostServ'],
|
|
5223
|
+
['HS', 'HostServ'],
|
|
5224
|
+
['MEMOSERV', 'MemoServ'],
|
|
5225
|
+
['MS', 'MemoServ'],
|
|
5226
|
+
['OPERSERV', 'OperServ'],
|
|
5227
|
+
['OS', 'OperServ'],
|
|
5228
|
+
];
|
|
5229
|
+
|
|
5230
|
+
for (const [alias, service] of aliasToService) {
|
|
5231
|
+
it(`${alias} (no args) routes to ${service} via the help NOTICE`, async () => {
|
|
5232
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5233
|
+
const { actor, runtime } = makeActor({ services });
|
|
5234
|
+
runtime.calls.length = 0;
|
|
5235
|
+
|
|
5236
|
+
await actor.receiveTextFrame(`${alias}\r\n`);
|
|
5237
|
+
|
|
5238
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5239
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5240
|
+
// The help NOTICE source hostmask identifies which service was routed.
|
|
5241
|
+
expect(
|
|
5242
|
+
allText.some((t) => t.startsWith(`:${service}!${service}@services NOTICE alice :`)),
|
|
5243
|
+
).toBe(true);
|
|
5244
|
+
// And it never produces a 421.
|
|
5245
|
+
for (const t of allText) {
|
|
5246
|
+
expect(t).not.toContain(' 421 ');
|
|
5247
|
+
}
|
|
5248
|
+
});
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
it('aliases are case-insensitive (ns lower-case maps to NickServ)', async () => {
|
|
5252
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5253
|
+
const { actor, runtime } = makeActor({ services });
|
|
5254
|
+
runtime.calls.length = 0;
|
|
5255
|
+
|
|
5256
|
+
await actor.receiveTextFrame('ns\r\n');
|
|
5257
|
+
|
|
5258
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5259
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5260
|
+
expect(allText.some((t) => t.startsWith(':NickServ!NickServ@services NOTICE alice :'))).toBe(
|
|
5261
|
+
true,
|
|
5262
|
+
);
|
|
5263
|
+
});
|
|
5264
|
+
|
|
5265
|
+
it('joins multi-word args into the PRIVMSG trailing body (NICKSERV REGISTER pw email)', async () => {
|
|
5266
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5267
|
+
const { actor, runtime } = makeActor({ services });
|
|
5268
|
+
runtime.calls.length = 0;
|
|
5269
|
+
|
|
5270
|
+
// Three trailing tokens must be joined and delivered as the body, exactly
|
|
5271
|
+
// mirroring `PRIVMSG NickServ :REGISTER hunter2 alice@example.com`.
|
|
5272
|
+
await actor.receiveTextFrame('NICKSERV REGISTER hunter2 alice@example.com\r\n');
|
|
5273
|
+
|
|
5274
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
5275
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
5276
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5277
|
+
expect(allText).toContain(
|
|
5278
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
|
|
5279
|
+
);
|
|
5280
|
+
});
|
|
5281
|
+
|
|
5282
|
+
it('joins a colon-trailing arg shape into the PRIVMSG body (NS :IDENTIFY hunter2)', async () => {
|
|
5283
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5284
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
5285
|
+
const { actor, conn } = makeActor({ services });
|
|
5286
|
+
|
|
5287
|
+
// `NS :IDENTIFY hunter2` parses to params=['IDENTIFY hunter2']; joining
|
|
5288
|
+
// reproduces the body `IDENTIFY hunter2` so the identify succeeds.
|
|
5289
|
+
await actor.receiveTextFrame('NS :IDENTIFY hunter2\r\n');
|
|
5290
|
+
|
|
5291
|
+
expect(conn.account).toBe('alice');
|
|
5292
|
+
});
|
|
5293
|
+
|
|
5294
|
+
it('shortcut with no args is equivalent to PRIVMSG <ServiceNick> (help NOTICE)', async () => {
|
|
5295
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
5296
|
+
const { actor: a1, runtime: r1 } = makeActor({ services });
|
|
5297
|
+
r1.calls.length = 0;
|
|
5298
|
+
await a1.receiveTextFrame('CS\r\n');
|
|
5299
|
+
const shortcutText = r1.calls
|
|
5300
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5301
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5302
|
+
|
|
5303
|
+
const { actor: a2, runtime: r2 } = makeActor({ services });
|
|
5304
|
+
r2.calls.length = 0;
|
|
5305
|
+
await a2.receiveTextFrame('PRIVMSG ChanServ :\r\n');
|
|
5306
|
+
const privmsgText = r2.calls
|
|
5307
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5308
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5309
|
+
|
|
5310
|
+
expect(shortcutText).toEqual(privmsgText);
|
|
5311
|
+
});
|
|
5312
|
+
|
|
5313
|
+
it('when services are unavailable, the shortcut matches a direct PRIVMSG to the service (401, never 421)', async () => {
|
|
5314
|
+
// No services store bound → the rewritten PRIVMSG NickServ falls through
|
|
5315
|
+
// to the normal user-target path (sendToNick + 401), exactly like a
|
|
5316
|
+
// direct PRIVMSG NickServ. The shortcut must NOT produce a bare 421.
|
|
5317
|
+
const { actor: shortcutActor, runtime: shortcutRuntime } = makeActor({});
|
|
5318
|
+
shortcutRuntime.calls.length = 0;
|
|
5319
|
+
await shortcutActor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
|
|
5320
|
+
|
|
5321
|
+
const shortcutToNick = shortcutRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5322
|
+
expect(shortcutToNick).toHaveLength(1);
|
|
5323
|
+
const shortcutNotFound = shortcutToNick[0]?.args[3] as RawLine[] | undefined;
|
|
5324
|
+
expect(shortcutNotFound?.[0]?.text).toContain('401');
|
|
5325
|
+
|
|
5326
|
+
// Equivalence: a direct PRIVMSG NickServ with no store produces the same
|
|
5327
|
+
// 401 path, confirming the shortcut delegates identically.
|
|
5328
|
+
const { actor: directActor, runtime: directRuntime } = makeActor({});
|
|
5329
|
+
directRuntime.calls.length = 0;
|
|
5330
|
+
await directActor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
5331
|
+
const directToNick = directRuntime.calls.filter((c) => c.method === 'sendToNick');
|
|
5332
|
+
expect(directToNick).toHaveLength(1);
|
|
5333
|
+
|
|
5334
|
+
// Critical: neither path emits a bare 421 for the shortcut verb.
|
|
5335
|
+
const allShortcutText = shortcutRuntime.calls
|
|
5336
|
+
.filter((c) => c.method === 'send' && c.args[0] === 'c1')
|
|
5337
|
+
.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
5338
|
+
for (const t of allShortcutText) {
|
|
5339
|
+
expect(t).not.toContain(' 421 ');
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
});
|