serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- 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-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 +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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 +546 -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 +5 -0
- 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 +15 -0
- 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/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 +127 -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/handlers.test.ts +105 -3
- 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 +112 -2
- 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.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 +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- 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 +390 -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 +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- 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 +21 -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 +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -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 +807 -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 +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- 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 +71 -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 +623 -12
- 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 +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- 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 };
|
|
@@ -1229,9 +1230,13 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1229
1230
|
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1230
1231
|
|
|
1231
1232
|
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
expect(
|
|
1233
|
+
// OPER success emits two Send effects: the 381 numeric, then a
|
|
1234
|
+
// `:<hostmask> MODE <nick> +o` echo (mirrors the user MODE echo).
|
|
1235
|
+
expect(sends).toHaveLength(2);
|
|
1236
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1237
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
1238
|
+
const secondLines = sends[1]?.args[1] as RawLine[];
|
|
1239
|
+
expect(secondLines[0]?.text).toBe(':alice!alice@example.com MODE alice +o');
|
|
1235
1240
|
expect(conn.userModes.oper).toBe(true);
|
|
1236
1241
|
// Critical: never 421 for OPER.
|
|
1237
1242
|
for (const s of sends) {
|
|
@@ -1307,6 +1312,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1307
1312
|
wallops: false,
|
|
1308
1313
|
serverNotices: false,
|
|
1309
1314
|
tls: false,
|
|
1315
|
+
registered: false,
|
|
1310
1316
|
},
|
|
1311
1317
|
},
|
|
1312
1318
|
});
|
|
@@ -1359,6 +1365,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1359
1365
|
wallops: false,
|
|
1360
1366
|
serverNotices: false,
|
|
1361
1367
|
tls: false,
|
|
1368
|
+
registered: false,
|
|
1362
1369
|
},
|
|
1363
1370
|
},
|
|
1364
1371
|
});
|
|
@@ -1387,6 +1394,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1387
1394
|
wallops: false,
|
|
1388
1395
|
serverNotices: false,
|
|
1389
1396
|
tls: false,
|
|
1397
|
+
registered: false,
|
|
1390
1398
|
},
|
|
1391
1399
|
},
|
|
1392
1400
|
configSource: 'KV',
|
|
@@ -1456,6 +1464,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1456
1464
|
wallops: false,
|
|
1457
1465
|
serverNotices: false,
|
|
1458
1466
|
tls: false,
|
|
1467
|
+
registered: false,
|
|
1459
1468
|
},
|
|
1460
1469
|
},
|
|
1461
1470
|
configSource: 'KV',
|
|
@@ -1495,6 +1504,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1495
1504
|
wallops: false,
|
|
1496
1505
|
serverNotices: false,
|
|
1497
1506
|
tls: false,
|
|
1507
|
+
registered: false,
|
|
1498
1508
|
},
|
|
1499
1509
|
},
|
|
1500
1510
|
configSource: 'KV',
|
|
@@ -1528,6 +1538,7 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1528
1538
|
wallops: false,
|
|
1529
1539
|
serverNotices: false,
|
|
1530
1540
|
tls: false,
|
|
1541
|
+
registered: false,
|
|
1531
1542
|
},
|
|
1532
1543
|
},
|
|
1533
1544
|
configSource: 'KV',
|
|
@@ -4020,9 +4031,9 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
|
|
|
4020
4031
|
expect(away.get('alice')).toBeUndefined();
|
|
4021
4032
|
});
|
|
4022
4033
|
|
|
4023
|
-
it('persists a +draft/read-marker TAGMSG to the bound
|
|
4024
|
-
const
|
|
4025
|
-
const { actor, runtime, conn } = makeActor({
|
|
4034
|
+
it('persists a +draft/read-marker TAGMSG to the bound ServicesStore for an identified member', async () => {
|
|
4035
|
+
const services = new InMemoryServicesStore();
|
|
4036
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4026
4037
|
conn.account = 'alice';
|
|
4027
4038
|
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4028
4039
|
await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
|
|
@@ -4030,7 +4041,225 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
|
|
|
4030
4041
|
|
|
4031
4042
|
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4032
4043
|
|
|
4033
|
-
expect(
|
|
4044
|
+
expect(services.getLastReadMarker('alice', '#foo')).toBe('msg-42');
|
|
4045
|
+
});
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
// ---------------------------------------------------------------------------
|
|
4049
|
+
// draft/read-marker — account-scoped fanout (spec compliance)
|
|
4050
|
+
//
|
|
4051
|
+
// The ircv3 draft/read-marker spec mandates that a read marker is private to
|
|
4052
|
+
// the originator's account: the mark TAGMSG is delivered only to the
|
|
4053
|
+
// originator's own connections, never to other accounts in the channel. The
|
|
4054
|
+
// reducer annotates the Broadcast with the originator account; dispatch
|
|
4055
|
+
// restricts the recipient set accordingly. These end-to-end tests drive the
|
|
4056
|
+
// full actor → reducer → dispatch → FakeRuntime path.
|
|
4057
|
+
// ---------------------------------------------------------------------------
|
|
4058
|
+
|
|
4059
|
+
describe('ConnectionActor — draft/read-marker account-scoped fanout', () => {
|
|
4060
|
+
function addIdentifiedMember(
|
|
4061
|
+
runtime: FakeRuntime,
|
|
4062
|
+
connId: ConnId,
|
|
4063
|
+
nick: string,
|
|
4064
|
+
account: string | undefined,
|
|
4065
|
+
caps: ReadonlyArray<string>,
|
|
4066
|
+
): ConnectionState {
|
|
4067
|
+
const state = createConnection({ id: connId, connectedSince: 0 });
|
|
4068
|
+
state.nick = nick;
|
|
4069
|
+
state.user = nick;
|
|
4070
|
+
state.host = `${nick}.example`;
|
|
4071
|
+
state.realname = nick;
|
|
4072
|
+
state.registration = 'registered';
|
|
4073
|
+
state.caps = new Set<string>(caps);
|
|
4074
|
+
if (account !== undefined) state.account = account;
|
|
4075
|
+
runtime.addConnection(state);
|
|
4076
|
+
return state;
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
async function seedChannel(
|
|
4080
|
+
runtime: FakeRuntime,
|
|
4081
|
+
members: ReadonlyArray<{ conn: ConnId; nick: string }>,
|
|
4082
|
+
): Promise<void> {
|
|
4083
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4084
|
+
memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
it('delivers the mark ONLY to same-account members; other accounts never receive it', async () => {
|
|
4089
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4090
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4091
|
+
conn.account = 'alice';
|
|
4092
|
+
// c2 = alice's other session (same account); c3 = bob (different account).
|
|
4093
|
+
addIdentifiedMember(runtime, 'c2', 'alice2', 'alice', ['message-tags', 'draft/read-marker']);
|
|
4094
|
+
addIdentifiedMember(runtime, 'c3', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4095
|
+
await seedChannel(runtime, [
|
|
4096
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4097
|
+
{ conn: 'c2', nick: 'alice2' },
|
|
4098
|
+
{ conn: 'c3', nick: 'bob' },
|
|
4099
|
+
]);
|
|
4100
|
+
runtime.calls.length = 0;
|
|
4101
|
+
|
|
4102
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
|
|
4103
|
+
|
|
4104
|
+
// alice's other session (c2) receives the mark...
|
|
4105
|
+
expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-42'))).toBe(
|
|
4106
|
+
true,
|
|
4107
|
+
);
|
|
4108
|
+
// ...bob (c3, different account) never does.
|
|
4109
|
+
expect(sentTo(runtime, 'c3')).toEqual([]);
|
|
4110
|
+
});
|
|
4111
|
+
|
|
4112
|
+
it("delivers the mark to the originator's other session on the same account (multi-device sync)", async () => {
|
|
4113
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4114
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4115
|
+
conn.account = 'alice';
|
|
4116
|
+
addIdentifiedMember(runtime, 'c2', 'alice-laptop', 'alice', [
|
|
4117
|
+
'message-tags',
|
|
4118
|
+
'draft/read-marker',
|
|
4119
|
+
]);
|
|
4120
|
+
await seedChannel(runtime, [
|
|
4121
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4122
|
+
{ conn: 'c2', nick: 'alice-laptop' },
|
|
4123
|
+
]);
|
|
4124
|
+
runtime.calls.length = 0;
|
|
4125
|
+
|
|
4126
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-99 TAGMSG #foo\r\n');
|
|
4127
|
+
|
|
4128
|
+
expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-99'))).toBe(
|
|
4129
|
+
true,
|
|
4130
|
+
);
|
|
4131
|
+
});
|
|
4132
|
+
|
|
4133
|
+
it('does NOT fan out a mark from an unidentified originator to anyone', async () => {
|
|
4134
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4135
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4136
|
+
// originator unidentified (no account).
|
|
4137
|
+
addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4138
|
+
addIdentifiedMember(runtime, 'c3', 'carol', undefined, ['message-tags', 'draft/read-marker']);
|
|
4139
|
+
await seedChannel(runtime, [
|
|
4140
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4141
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4142
|
+
{ conn: 'c3', nick: 'carol' },
|
|
4143
|
+
]);
|
|
4144
|
+
runtime.calls.length = 0;
|
|
4145
|
+
|
|
4146
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
|
|
4147
|
+
|
|
4148
|
+
// No recipient receives the mark (session-local only).
|
|
4149
|
+
expect(sentTo(runtime, 'c2')).toEqual([]);
|
|
4150
|
+
expect(sentTo(runtime, 'c3')).toEqual([]);
|
|
4151
|
+
});
|
|
4152
|
+
|
|
4153
|
+
it('self-echoes the mark to an unidentified originator only when echo-message is negotiated', async () => {
|
|
4154
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4155
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker', 'echo-message']);
|
|
4156
|
+
// originator unidentified (no account).
|
|
4157
|
+
addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
|
|
4158
|
+
await seedChannel(runtime, [
|
|
4159
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4160
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4161
|
+
]);
|
|
4162
|
+
runtime.calls.length = 0;
|
|
4163
|
+
|
|
4164
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
|
|
4165
|
+
|
|
4166
|
+
// No fanout to bob...
|
|
4167
|
+
expect(sentTo(runtime, 'c2')).toEqual([]);
|
|
4168
|
+
// ...but the originator sees its own mark reflected back via echo-message.
|
|
4169
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('+draft/read-marker=msg-1'))).toBe(
|
|
4170
|
+
true,
|
|
4171
|
+
);
|
|
4172
|
+
});
|
|
4173
|
+
|
|
4174
|
+
it('keeps the +draft/read-marker tag for a draft/read-marker-only same-account peer via the filterClientTags whitelist', async () => {
|
|
4175
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4176
|
+
conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
|
|
4177
|
+
conn.account = 'alice';
|
|
4178
|
+
// c2 announced ONLY draft/read-marker (not message-tags). The whitelist
|
|
4179
|
+
// in filterClientTags keeps the +draft/read-marker client tag for it.
|
|
4180
|
+
addIdentifiedMember(runtime, 'c2', 'alice-min', 'alice', ['draft/read-marker']);
|
|
4181
|
+
await seedChannel(runtime, [
|
|
4182
|
+
{ conn: 'c1', nick: 'alice' },
|
|
4183
|
+
{ conn: 'c2', nick: 'alice-min' },
|
|
4184
|
+
]);
|
|
4185
|
+
runtime.calls.length = 0;
|
|
4186
|
+
|
|
4187
|
+
await actor.receiveTextFrame('@+draft/read-marker=msg-5 TAGMSG #foo\r\n');
|
|
4188
|
+
|
|
4189
|
+
const lines = sentTo(runtime, 'c2');
|
|
4190
|
+
expect(lines).toHaveLength(1);
|
|
4191
|
+
// The tag survives the whitelist for a draft/read-marker-only peer.
|
|
4192
|
+
expect(lines[0]?.text).toBe('@+draft/read-marker=msg-5 :alice!alice@example.com TAGMSG #foo');
|
|
4193
|
+
});
|
|
4194
|
+
});
|
|
4195
|
+
|
|
4196
|
+
// ---------------------------------------------------------------------------
|
|
4197
|
+
// draft/read-marker — MARKREAD verb (draft-spec client compatibility)
|
|
4198
|
+
//
|
|
4199
|
+
// The deployment stores read markers by msgid but accepts the draft's
|
|
4200
|
+
// timestamped MARKREAD verb from cap-enabled clients, translating the
|
|
4201
|
+
// timestamp to the most recent retained message at or before it. These
|
|
4202
|
+
// end-to-end tests drive the full actor -> reducer path and assert the verb
|
|
4203
|
+
// is routed (never 421 for a cap-enabled client).
|
|
4204
|
+
// ---------------------------------------------------------------------------
|
|
4205
|
+
|
|
4206
|
+
describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
|
|
4207
|
+
const TS = (ms: number): string => `timestamp=${formatServerTime(ms)}`;
|
|
4208
|
+
|
|
4209
|
+
/** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
|
|
4210
|
+
function seedMessages(store: MessageStore, chan: string, count: number): void {
|
|
4211
|
+
for (let i = 1; i <= count; i++) {
|
|
4212
|
+
const m: StoredMessage = {
|
|
4213
|
+
msgid: `m${i}`,
|
|
4214
|
+
time: i * 1_000,
|
|
4215
|
+
chan: chan.toLowerCase(),
|
|
4216
|
+
command: 'PRIVMSG',
|
|
4217
|
+
nick: 'bob',
|
|
4218
|
+
user: 'bob',
|
|
4219
|
+
host: 'ex.org',
|
|
4220
|
+
text: `msg ${i}`,
|
|
4221
|
+
};
|
|
4222
|
+
store.record(m);
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
it('routes MARKREAD to the reducer (never 421) and echoes the resolved timestamp', async () => {
|
|
4227
|
+
const store = new InMemoryMessageStore();
|
|
4228
|
+
const { actor, runtime, conn } = makeActor({ messages: store });
|
|
4229
|
+
conn.caps = new Set<string>(['draft/read-marker']);
|
|
4230
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4231
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
4232
|
+
});
|
|
4233
|
+
seedMessages(store, '#foo', 3); // m1=1s, m2=2s, m3=3s
|
|
4234
|
+
runtime.calls.length = 0;
|
|
4235
|
+
|
|
4236
|
+
await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
|
|
4237
|
+
|
|
4238
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4239
|
+
expect(sends).toHaveLength(1);
|
|
4240
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4241
|
+
expect(lines[0]?.text).toBe(`:irc.example.com MARKREAD #foo ${TS(2_000)}`);
|
|
4242
|
+
// Critical: never 421 for a cap-enabled MARKREAD.
|
|
4243
|
+
expect(lines[0]?.text).not.toContain(' 421 ');
|
|
4244
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
|
|
4245
|
+
});
|
|
4246
|
+
|
|
4247
|
+
it('still emits 421 for MARKREAD when the cap is not negotiated (reducer cap-gating)', async () => {
|
|
4248
|
+
const store = new InMemoryMessageStore();
|
|
4249
|
+
const { actor, runtime } = makeActor({ messages: store });
|
|
4250
|
+
// No draft/read-marker cap.
|
|
4251
|
+
await runtime.applyChannelDelta('#foo', {
|
|
4252
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
4253
|
+
});
|
|
4254
|
+
seedMessages(store, '#foo', 3);
|
|
4255
|
+
runtime.calls.length = 0;
|
|
4256
|
+
|
|
4257
|
+
await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
|
|
4258
|
+
|
|
4259
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
4260
|
+
expect(sends).toHaveLength(1);
|
|
4261
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
4262
|
+
expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
|
|
4034
4263
|
});
|
|
4035
4264
|
});
|
|
4036
4265
|
|
|
@@ -4367,3 +4596,385 @@ describe('ConnectionActor — draft/multiline chathistory recording', () => {
|
|
|
4367
4596
|
expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
|
|
4368
4597
|
});
|
|
4369
4598
|
});
|
|
4599
|
+
|
|
4600
|
+
// ============================================================================
|
|
4601
|
+
// NickServ routing — PRIVMSG NickServ :<command>
|
|
4602
|
+
// ============================================================================
|
|
4603
|
+
|
|
4604
|
+
describe('ConnectionActor — NickServ routing', () => {
|
|
4605
|
+
it('routes PRIVMSG NickServ :IDENTIFY to the NickServ reducer and sets +r', async () => {
|
|
4606
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4607
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4608
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4609
|
+
runtime.calls.length = 0;
|
|
4610
|
+
|
|
4611
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4612
|
+
|
|
4613
|
+
expect(conn.account).toBe('alice');
|
|
4614
|
+
expect(conn.userModes.registered).toBe(true);
|
|
4615
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4616
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4617
|
+
expect(allText).toContain(
|
|
4618
|
+
':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
|
|
4619
|
+
);
|
|
4620
|
+
});
|
|
4621
|
+
|
|
4622
|
+
it('routes PRIVMSG NickServ :REGISTER and confirms via NOTICE', async () => {
|
|
4623
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4624
|
+
const { actor, runtime } = makeActor({ services });
|
|
4625
|
+
runtime.calls.length = 0;
|
|
4626
|
+
|
|
4627
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :REGISTER hunter2 alice@example.com\r\n');
|
|
4628
|
+
|
|
4629
|
+
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
4630
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4631
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4632
|
+
expect(allText).toContain(
|
|
4633
|
+
':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
|
|
4634
|
+
);
|
|
4635
|
+
});
|
|
4636
|
+
|
|
4637
|
+
it('does NOT route NOTICE NickServ to the NickServ reducer (RFC: NOTICE is silent)', async () => {
|
|
4638
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4639
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4640
|
+
runtime.calls.length = 0;
|
|
4641
|
+
|
|
4642
|
+
await actor.receiveTextFrame('NOTICE NickServ :IDENTIFY hunter2\r\n');
|
|
4643
|
+
|
|
4644
|
+
// NOTICE to NickServ falls through to the normal user-target path (no
|
|
4645
|
+
// NickServ NOTICE emitted, no state change).
|
|
4646
|
+
expect(conn.account).toBeUndefined();
|
|
4647
|
+
const nickservNotices = runtime.calls.filter(
|
|
4648
|
+
(c) =>
|
|
4649
|
+
c.method === 'send' &&
|
|
4650
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('NickServ!NickServ@services')),
|
|
4651
|
+
);
|
|
4652
|
+
expect(nickservNotices).toHaveLength(0);
|
|
4653
|
+
});
|
|
4654
|
+
|
|
4655
|
+
it('treats PRIVMSG NickServ as a normal nick-target when no services store is bound', async () => {
|
|
4656
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4657
|
+
runtime.calls.length = 0;
|
|
4658
|
+
|
|
4659
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4660
|
+
|
|
4661
|
+
// Without a services store, NickServ is just a nick target → the normal
|
|
4662
|
+
// user-target reducer emits a SendToNick with a 401 fallback.
|
|
4663
|
+
expect(conn.account).toBeUndefined();
|
|
4664
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4665
|
+
expect(toNick).toHaveLength(1);
|
|
4666
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4667
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4668
|
+
});
|
|
4669
|
+
|
|
4670
|
+
it('routes a case-insensitive PRIVMSG nickserv :… to the NickServ reducer', async () => {
|
|
4671
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4672
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4673
|
+
const { actor, conn } = makeActor({ services });
|
|
4674
|
+
|
|
4675
|
+
await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
|
|
4676
|
+
|
|
4677
|
+
expect(conn.account).toBe('alice');
|
|
4678
|
+
});
|
|
4679
|
+
});
|
|
4680
|
+
|
|
4681
|
+
// ============================================================================
|
|
4682
|
+
// ChanServ routing — PRIVMSG ChanServ :<command>
|
|
4683
|
+
// ============================================================================
|
|
4684
|
+
|
|
4685
|
+
describe('ConnectionActor — ChanServ routing', () => {
|
|
4686
|
+
it('routes PRIVMSG ChanServ :REGISTER and applies the +r ApplyChannelDelta', async () => {
|
|
4687
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4688
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4689
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4690
|
+
conn.account = 'alice';
|
|
4691
|
+
conn.userModes.registered = true;
|
|
4692
|
+
runtime.calls.length = 0;
|
|
4693
|
+
|
|
4694
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
|
|
4695
|
+
|
|
4696
|
+
expect(services.getChannelFounder('#home')).toBe('alice');
|
|
4697
|
+
const applyDeltas = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
|
|
4698
|
+
expect(applyDeltas.length).toBeGreaterThan(0);
|
|
4699
|
+
});
|
|
4700
|
+
|
|
4701
|
+
it('routes PRIVMSG ChanServ :SET KEEPTOPIC #home ON and persists', async () => {
|
|
4702
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4703
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4704
|
+
services.registerChannel('#home', 'alice');
|
|
4705
|
+
const { actor, conn } = makeActor({ services });
|
|
4706
|
+
conn.account = 'alice';
|
|
4707
|
+
conn.userModes.registered = true;
|
|
4708
|
+
|
|
4709
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :SET KEEPTOPIC #home ON\r\n');
|
|
4710
|
+
|
|
4711
|
+
expect(services.getChannel('#home')?.keepTopic).toBe(true);
|
|
4712
|
+
});
|
|
4713
|
+
|
|
4714
|
+
it('does NOT route NOTICE ChanServ to the ChanServ reducer', async () => {
|
|
4715
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4716
|
+
const { actor, runtime } = makeActor({ services });
|
|
4717
|
+
runtime.calls.length = 0;
|
|
4718
|
+
|
|
4719
|
+
await actor.receiveTextFrame('NOTICE ChanServ :REGISTER #home\r\n');
|
|
4720
|
+
|
|
4721
|
+
const chanservNotices = runtime.calls.filter(
|
|
4722
|
+
(c) =>
|
|
4723
|
+
c.method === 'send' &&
|
|
4724
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('ChanServ!ChanServ@services')),
|
|
4725
|
+
);
|
|
4726
|
+
expect(chanservNotices).toHaveLength(0);
|
|
4727
|
+
});
|
|
4728
|
+
|
|
4729
|
+
it('treats PRIVMSG ChanServ as a normal nick-target when no services store is bound', async () => {
|
|
4730
|
+
const { actor, runtime } = makeActor({});
|
|
4731
|
+
runtime.calls.length = 0;
|
|
4732
|
+
|
|
4733
|
+
await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
|
|
4734
|
+
|
|
4735
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4736
|
+
expect(toNick).toHaveLength(1);
|
|
4737
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4738
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4739
|
+
});
|
|
4740
|
+
|
|
4741
|
+
it('routes a case-insensitive PRIVMSG chanserv :… to the ChanServ reducer', async () => {
|
|
4742
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4743
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4744
|
+
const { actor, conn } = makeActor({ services });
|
|
4745
|
+
conn.account = 'alice';
|
|
4746
|
+
conn.userModes.registered = true;
|
|
4747
|
+
|
|
4748
|
+
await actor.receiveTextFrame('PRIVMSG chanserv :REGISTER #home\r\n');
|
|
4749
|
+
|
|
4750
|
+
expect(services.getChannelFounder('#home')).toBe('alice');
|
|
4751
|
+
});
|
|
4752
|
+
});
|
|
4753
|
+
|
|
4754
|
+
// ============================================================================
|
|
4755
|
+
// MemoServ routing — PRIVMSG MemoServ :<command>
|
|
4756
|
+
// ============================================================================
|
|
4757
|
+
|
|
4758
|
+
describe('ConnectionActor — MemoServ routing', () => {
|
|
4759
|
+
it('routes PRIVMSG MemoServ :SEND and records the memo against the recipient', async () => {
|
|
4760
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4761
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4762
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
4763
|
+
const { actor, conn } = makeActor({ services });
|
|
4764
|
+
conn.account = 'alice';
|
|
4765
|
+
conn.userModes.registered = true;
|
|
4766
|
+
|
|
4767
|
+
await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi from actor\r\n');
|
|
4768
|
+
|
|
4769
|
+
const memos = services.listMemos('bob');
|
|
4770
|
+
expect(memos).toHaveLength(1);
|
|
4771
|
+
expect(memos[0]?.body).toBe('Hi from actor');
|
|
4772
|
+
});
|
|
4773
|
+
|
|
4774
|
+
it('delivers a queued memo via MemoServ NOTICE on NickServ IDENTIFY', async () => {
|
|
4775
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4776
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4777
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'queued', sentAt: 1_000 });
|
|
4778
|
+
const { actor, runtime } = makeActor({ services });
|
|
4779
|
+
runtime.calls.length = 0;
|
|
4780
|
+
|
|
4781
|
+
await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
|
|
4782
|
+
|
|
4783
|
+
const memoNotices = runtime.calls.filter(
|
|
4784
|
+
(c) =>
|
|
4785
|
+
c.method === 'send' &&
|
|
4786
|
+
(c.args[1] as RawLine[]).some((l) =>
|
|
4787
|
+
l.text.includes('MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] queued'),
|
|
4788
|
+
),
|
|
4789
|
+
);
|
|
4790
|
+
expect(memoNotices).toHaveLength(1);
|
|
4791
|
+
});
|
|
4792
|
+
|
|
4793
|
+
it('does NOT route NOTICE MemoServ to the MemoServ reducer', async () => {
|
|
4794
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4795
|
+
const { actor, runtime } = makeActor({ services });
|
|
4796
|
+
runtime.calls.length = 0;
|
|
4797
|
+
|
|
4798
|
+
await actor.receiveTextFrame('NOTICE MemoServ :SEND bob Hi\r\n');
|
|
4799
|
+
|
|
4800
|
+
const memoservNotices = runtime.calls.filter(
|
|
4801
|
+
(c) =>
|
|
4802
|
+
c.method === 'send' &&
|
|
4803
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('MemoServ!MemoServ@services')),
|
|
4804
|
+
);
|
|
4805
|
+
expect(memoservNotices).toHaveLength(0);
|
|
4806
|
+
});
|
|
4807
|
+
|
|
4808
|
+
it('treats PRIVMSG MemoServ as a normal nick-target when no services store is bound', async () => {
|
|
4809
|
+
const { actor, runtime } = makeActor({});
|
|
4810
|
+
runtime.calls.length = 0;
|
|
4811
|
+
|
|
4812
|
+
await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi\r\n');
|
|
4813
|
+
|
|
4814
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4815
|
+
expect(toNick).toHaveLength(1);
|
|
4816
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4817
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4818
|
+
});
|
|
4819
|
+
|
|
4820
|
+
it('routes a case-insensitive PRIVMSG memoserv :… to the MemoServ reducer', async () => {
|
|
4821
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4822
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
4823
|
+
services.registerNick('bob', 'hunter2', 'bob@example.com');
|
|
4824
|
+
const { actor, conn } = makeActor({ services });
|
|
4825
|
+
conn.account = 'alice';
|
|
4826
|
+
conn.userModes.registered = true;
|
|
4827
|
+
|
|
4828
|
+
await actor.receiveTextFrame('PRIVMSG memoserv :SEND bob lower-case\r\n');
|
|
4829
|
+
|
|
4830
|
+
expect(services.listMemos('bob')[0]?.body).toBe('lower-case');
|
|
4831
|
+
});
|
|
4832
|
+
});
|
|
4833
|
+
|
|
4834
|
+
// ============================================================================
|
|
4835
|
+
// OperServ routing — PRIVMSG OperServ :<command> (oper-gated management)
|
|
4836
|
+
// ============================================================================
|
|
4837
|
+
|
|
4838
|
+
describe('ConnectionActor — OperServ routing', () => {
|
|
4839
|
+
it('routes PRIVMSG OperServ :AKILL LIST from an oper to the OperServ reducer (not 401)', async () => {
|
|
4840
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4841
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4842
|
+
conn.userModes.oper = true;
|
|
4843
|
+
runtime.calls.length = 0;
|
|
4844
|
+
|
|
4845
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
|
|
4846
|
+
|
|
4847
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4848
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4849
|
+
expect(allText).toContain(
|
|
4850
|
+
':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
|
|
4851
|
+
);
|
|
4852
|
+
// Critical: never 401 for a routed OperServ PRIVMSG.
|
|
4853
|
+
for (const t of allText) {
|
|
4854
|
+
expect(t).not.toContain(' 401 ');
|
|
4855
|
+
}
|
|
4856
|
+
});
|
|
4857
|
+
|
|
4858
|
+
it('round-trips oper AKILL ADD / DEL via PRIVMSG OperServ', async () => {
|
|
4859
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4860
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4861
|
+
conn.userModes.oper = true;
|
|
4862
|
+
runtime.calls.length = 0;
|
|
4863
|
+
|
|
4864
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
|
|
4865
|
+
expect(services.listAkills()).toHaveLength(1);
|
|
4866
|
+
const addSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4867
|
+
const addText = addSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4868
|
+
expect(addText).toContain(
|
|
4869
|
+
':OperServ!OperServ@services NOTICE alice :AKILL *bad@host added (reason: flooding).',
|
|
4870
|
+
);
|
|
4871
|
+
|
|
4872
|
+
runtime.calls.length = 0;
|
|
4873
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL DEL *bad@host\r\n');
|
|
4874
|
+
expect(services.listAkills()).toHaveLength(0);
|
|
4875
|
+
const delSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4876
|
+
const delText = delSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4877
|
+
expect(delText).toContain(':OperServ!OperServ@services NOTICE alice :AKILL *bad@host removed.');
|
|
4878
|
+
});
|
|
4879
|
+
|
|
4880
|
+
it('round-trips oper JUPE / UNJUPE via PRIVMSG OperServ', async () => {
|
|
4881
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4882
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4883
|
+
conn.userModes.oper = true;
|
|
4884
|
+
runtime.calls.length = 0;
|
|
4885
|
+
|
|
4886
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :JUPE malkovich\r\n');
|
|
4887
|
+
expect(services.isJuped('malkovich')).toBe(true);
|
|
4888
|
+
const jupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4889
|
+
const jupeText = jupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4890
|
+
expect(jupeText).toContain(':OperServ!OperServ@services NOTICE alice :JUPE malkovich added.');
|
|
4891
|
+
|
|
4892
|
+
runtime.calls.length = 0;
|
|
4893
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :UNJUPE malkovich\r\n');
|
|
4894
|
+
expect(services.isJuped('malkovich')).toBe(false);
|
|
4895
|
+
const unjupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4896
|
+
const unjupeText = unjupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4897
|
+
expect(unjupeText).toContain(
|
|
4898
|
+
':OperServ!OperServ@services NOTICE alice :JUPE malkovich removed.',
|
|
4899
|
+
);
|
|
4900
|
+
});
|
|
4901
|
+
|
|
4902
|
+
it('round-trips oper RAW via PRIVMSG OperServ', async () => {
|
|
4903
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4904
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4905
|
+
conn.userModes.oper = true;
|
|
4906
|
+
runtime.calls.length = 0;
|
|
4907
|
+
|
|
4908
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :RAW :srv.example.com STATS u\r\n');
|
|
4909
|
+
|
|
4910
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4911
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4912
|
+
expect(allText).toContain(
|
|
4913
|
+
':OperServ!OperServ@services NOTICE alice :RAW line accepted and logged.',
|
|
4914
|
+
);
|
|
4915
|
+
});
|
|
4916
|
+
|
|
4917
|
+
it('rejects a non-oper PRIVMSG OperServ :AKILL ADD with permission-denied NOTICE (not 401, no state change)', async () => {
|
|
4918
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4919
|
+
const { actor, runtime } = makeActor({ services });
|
|
4920
|
+
// conn.userModes.oper left false
|
|
4921
|
+
runtime.calls.length = 0;
|
|
4922
|
+
|
|
4923
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
|
|
4924
|
+
|
|
4925
|
+
expect(services.listAkills()).toHaveLength(0);
|
|
4926
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4927
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4928
|
+
expect(allText).toContain(
|
|
4929
|
+
':OperServ!OperServ@services NOTICE alice :Permission denied — oper privileges required.',
|
|
4930
|
+
);
|
|
4931
|
+
for (const t of allText) {
|
|
4932
|
+
expect(t).not.toContain(' 401 ');
|
|
4933
|
+
}
|
|
4934
|
+
});
|
|
4935
|
+
|
|
4936
|
+
it('does NOT route NOTICE OperServ to the OperServ reducer (PRIVMSG-only interception)', 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('NOTICE OperServ :AKILL LIST\r\n');
|
|
4943
|
+
|
|
4944
|
+
// NOTICE to OperServ falls through to the normal user-target path (no
|
|
4945
|
+
// OperServ NOTICE emitted, no state change).
|
|
4946
|
+
const operservNotices = runtime.calls.filter(
|
|
4947
|
+
(c) =>
|
|
4948
|
+
c.method === 'send' &&
|
|
4949
|
+
(c.args[1] as RawLine[]).some((l) => l.text.includes('OperServ!OperServ@services')),
|
|
4950
|
+
);
|
|
4951
|
+
expect(operservNotices).toHaveLength(0);
|
|
4952
|
+
});
|
|
4953
|
+
|
|
4954
|
+
it('treats PRIVMSG OperServ as a normal nick-target when no services store is bound', async () => {
|
|
4955
|
+
const { actor, runtime } = makeActor({});
|
|
4956
|
+
runtime.calls.length = 0;
|
|
4957
|
+
|
|
4958
|
+
await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
|
|
4959
|
+
|
|
4960
|
+
const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
4961
|
+
expect(toNick).toHaveLength(1);
|
|
4962
|
+
const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
|
|
4963
|
+
expect(notFound?.[0]?.text).toContain('401');
|
|
4964
|
+
});
|
|
4965
|
+
|
|
4966
|
+
it('routes a case-insensitive PRIVMSG operserv :… to the OperServ reducer', async () => {
|
|
4967
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
4968
|
+
const { actor, runtime, conn } = makeActor({ services });
|
|
4969
|
+
conn.userModes.oper = true;
|
|
4970
|
+
runtime.calls.length = 0;
|
|
4971
|
+
|
|
4972
|
+
await actor.receiveTextFrame('PRIVMSG operserv :AKILL LIST\r\n');
|
|
4973
|
+
|
|
4974
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
4975
|
+
const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
|
|
4976
|
+
expect(allText).toContain(
|
|
4977
|
+
':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
|
|
4978
|
+
);
|
|
4979
|
+
});
|
|
4980
|
+
});
|