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
|
@@ -8,15 +8,15 @@ import type {
|
|
|
8
8
|
AccountStore,
|
|
9
9
|
AwayStore,
|
|
10
10
|
MtlsIdentityProvider,
|
|
11
|
-
ReadMarkerStore,
|
|
12
11
|
SaslPayload,
|
|
13
12
|
SaslResult,
|
|
13
|
+
ServicesStore,
|
|
14
14
|
} from '../../src/ports';
|
|
15
15
|
import {
|
|
16
16
|
EmptyMotdProvider,
|
|
17
17
|
FakeClock,
|
|
18
18
|
InMemoryAwayStore,
|
|
19
|
-
|
|
19
|
+
InMemoryServicesStore,
|
|
20
20
|
SequentialIdFactory,
|
|
21
21
|
} from '../../src/ports';
|
|
22
22
|
import { encodeBase64 } from '../../src/protocol/base64';
|
|
@@ -57,7 +57,7 @@ function makeCtx(
|
|
|
57
57
|
state: ConnectionState,
|
|
58
58
|
accounts?: AccountStore,
|
|
59
59
|
mtlsIdentity?: MtlsIdentityProvider,
|
|
60
|
-
|
|
60
|
+
services?: ServicesStore,
|
|
61
61
|
away?: AwayStore,
|
|
62
62
|
): Ctx {
|
|
63
63
|
return buildCtx({
|
|
@@ -68,7 +68,7 @@ function makeCtx(
|
|
|
68
68
|
connection: state,
|
|
69
69
|
...(accounts !== undefined ? { accounts } : {}),
|
|
70
70
|
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
71
|
-
...(
|
|
71
|
+
...(services !== undefined ? { services } : {}),
|
|
72
72
|
...(away !== undefined ? { away } : {}),
|
|
73
73
|
});
|
|
74
74
|
}
|
|
@@ -161,13 +161,13 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
161
161
|
expect(state.account).toBe('alice');
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
it('seeds lastReadMarkers from the
|
|
164
|
+
it('seeds lastReadMarkers from the ServicesStore at identify', () => {
|
|
165
165
|
const state = saslReadyState();
|
|
166
166
|
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const ctx = makeCtx(state, accounts, undefined,
|
|
167
|
+
const services = new InMemoryServicesStore();
|
|
168
|
+
services.setLastReadMarker('alice', '#foo', 'm1');
|
|
169
|
+
services.setLastReadMarker('alice', '#bar', 'm2');
|
|
170
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
171
171
|
|
|
172
172
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
173
173
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -179,9 +179,9 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
179
179
|
it('does not seed markers for a different account', () => {
|
|
180
180
|
const state = saslReadyState();
|
|
181
181
|
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const ctx = makeCtx(state, accounts, undefined,
|
|
182
|
+
const services = new InMemoryServicesStore();
|
|
183
|
+
services.setLastReadMarker('bob', '#foo', 'm9');
|
|
184
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
185
185
|
|
|
186
186
|
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
187
187
|
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
@@ -258,6 +258,135 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
258
258
|
expect(state.away).toBe('brb');
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
it('delivers queued unread memos on a successful SASL login', () => {
|
|
262
|
+
const state = saslReadyState();
|
|
263
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
264
|
+
const services = new InMemoryServicesStore();
|
|
265
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
|
|
266
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
267
|
+
|
|
268
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
269
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
270
|
+
|
|
271
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
272
|
+
expect(texts.map((l) => l.text)).toContain(
|
|
273
|
+
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] Hi alice!',
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('marks delivered memos as read after a successful SASL login', () => {
|
|
278
|
+
const state = saslReadyState();
|
|
279
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
280
|
+
const services = new InMemoryServicesStore();
|
|
281
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
|
|
282
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
283
|
+
|
|
284
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
285
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
286
|
+
|
|
287
|
+
expect(services.listMemos('alice')[0]?.read).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not deliver already-read memos on a SASL login', () => {
|
|
291
|
+
const state = saslReadyState();
|
|
292
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
293
|
+
const services = new InMemoryServicesStore();
|
|
294
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'old', sentAt: 1_000 });
|
|
295
|
+
services.markMemoRead('alice', 1);
|
|
296
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
297
|
+
|
|
298
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
299
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
300
|
+
|
|
301
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
302
|
+
expect(texts.some((l) => l.text.includes('[Memo #1'))).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
|
|
306
|
+
const state = saslReadyState();
|
|
307
|
+
state.host = 'real.example.net';
|
|
308
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
309
|
+
const services = new InMemoryServicesStore();
|
|
310
|
+
services.setVhost('alice', 'cool.example.net');
|
|
311
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
312
|
+
|
|
313
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
314
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
315
|
+
|
|
316
|
+
expect(state.vhostActive).toBe(true);
|
|
317
|
+
expect(state.host).toBe('cool.example.net');
|
|
318
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
|
|
322
|
+
const state = saslReadyState();
|
|
323
|
+
state.host = 'real.example.net';
|
|
324
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
325
|
+
const services = new InMemoryServicesStore();
|
|
326
|
+
services.setVhost('alice', 'cool.example.net');
|
|
327
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
328
|
+
|
|
329
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
330
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
331
|
+
|
|
332
|
+
expect(out.effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('leaves host unchanged on SASL success when no vhost is assigned', () => {
|
|
336
|
+
const state = saslReadyState();
|
|
337
|
+
state.host = 'real.example.net';
|
|
338
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
339
|
+
const services = new InMemoryServicesStore();
|
|
340
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
341
|
+
|
|
342
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
343
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
344
|
+
|
|
345
|
+
expect(state.vhostActive).toBeUndefined();
|
|
346
|
+
expect(state.host).toBe('real.example.net');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('delivers nothing on a SASL login when the account has no memos', () => {
|
|
350
|
+
const state = saslReadyState();
|
|
351
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
352
|
+
const services = new InMemoryServicesStore();
|
|
353
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
354
|
+
|
|
355
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
356
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
357
|
+
|
|
358
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
359
|
+
expect(texts.some((l) => l.text.includes('MemoServ'))).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('looks up queued memos case-insensitively on a SASL login', () => {
|
|
363
|
+
const state = saslReadyState();
|
|
364
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
365
|
+
const services = new InMemoryServicesStore();
|
|
366
|
+
services.recordMemo({ from: 'bob', to: 'ALICE', body: 'folded', sentAt: 1_000 });
|
|
367
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
368
|
+
|
|
369
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
370
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
371
|
+
|
|
372
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
373
|
+
expect(texts.map((l) => l.text)).toContain(
|
|
374
|
+
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] folded',
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('does not deliver memos when no ServicesStore is bound', () => {
|
|
379
|
+
const state = saslReadyState();
|
|
380
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
381
|
+
const ctx = makeCtx(state, accounts);
|
|
382
|
+
|
|
383
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
384
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
385
|
+
|
|
386
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
387
|
+
expect(texts.some((l) => l.text.includes('MemoServ'))).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
261
390
|
it('clears the in-progress mechanism after a successful authentication', () => {
|
|
262
391
|
const state = saslReadyState();
|
|
263
392
|
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
@@ -432,6 +561,24 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
432
561
|
expect(state.saslBuffer).toBeUndefined();
|
|
433
562
|
});
|
|
434
563
|
|
|
564
|
+
it('delivers queued memos on a successful EXTERNAL login', () => {
|
|
565
|
+
const state = saslReadyState();
|
|
566
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
567
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
568
|
+
const services = new InMemoryServicesStore();
|
|
569
|
+
services.recordMemo({ from: 'bob', to: 'alice', body: 'via mTLS', sentAt: 1_000 });
|
|
570
|
+
const ctx = makeCtx(state, accounts, provider, services);
|
|
571
|
+
|
|
572
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
573
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
574
|
+
|
|
575
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
|
|
576
|
+
expect(texts.map((l) => l.text)).toContain(
|
|
577
|
+
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] via mTLS',
|
|
578
|
+
);
|
|
579
|
+
expect(services.listMemos('alice')[0]?.read).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
|
|
435
582
|
it('fails with 904 when the provider has no identity for the connection', () => {
|
|
436
583
|
const state = saslReadyState();
|
|
437
584
|
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
@@ -756,3 +903,73 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
|
|
|
756
903
|
expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
757
904
|
});
|
|
758
905
|
});
|
|
906
|
+
|
|
907
|
+
// ============================================================================
|
|
908
|
+
// SASL PLAIN — NickServ-registered accounts (ServicesStore fallback)
|
|
909
|
+
// ============================================================================
|
|
910
|
+
|
|
911
|
+
describe('authenticateReducer — NickServ-registered nick via ServicesStore', () => {
|
|
912
|
+
it('succeeds with credentials registered via NickServ when no AccountStore is bound', () => {
|
|
913
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
914
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
915
|
+
const state = saslReadyState();
|
|
916
|
+
const ctx = makeCtx(state, undefined, undefined, services);
|
|
917
|
+
|
|
918
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
919
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'hunter2')), ctx);
|
|
920
|
+
|
|
921
|
+
expect(state.account).toBe('alice');
|
|
922
|
+
const lines = sendLinesOf(out.effects[0]);
|
|
923
|
+
expect(lines.map((l) => l.text)).toContain(
|
|
924
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
925
|
+
);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('succeeds with NickServ credentials even when a static AccountStore is also bound', () => {
|
|
929
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
930
|
+
services.registerNick('bob', 'bobpass', 'bob@example.com');
|
|
931
|
+
// Static AccountStore does not know about bob; the services store does.
|
|
932
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
933
|
+
const state = saslReadyState();
|
|
934
|
+
state.nick = 'bob';
|
|
935
|
+
state.user = 'bob';
|
|
936
|
+
state.host = 'example.com';
|
|
937
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
938
|
+
|
|
939
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
940
|
+
const out = authenticateReducer(state, authenticate(plainPayload('bob', 'bobpass')), ctx);
|
|
941
|
+
|
|
942
|
+
expect(state.account).toBe('bob');
|
|
943
|
+
expect(sendLinesOf(out.effects[0]).map((l) => l.text)).toContain(
|
|
944
|
+
`:${SV} 903 bob :SASL authentication successful`,
|
|
945
|
+
);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('prefers the static AccountStore over the services store on a match', () => {
|
|
949
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
950
|
+
services.registerNick('alice', 'servicespw', 'alice@example.com');
|
|
951
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
952
|
+
const state = saslReadyState();
|
|
953
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
954
|
+
|
|
955
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
956
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'staticpw')), ctx);
|
|
957
|
+
|
|
958
|
+
// The static store vouched for the account; it is used.
|
|
959
|
+
expect(accounts.calls).toHaveLength(1);
|
|
960
|
+
expect(state.account).toBe('alice');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('fails when neither the AccountStore nor the ServicesStore verifies the credential', () => {
|
|
964
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
965
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
966
|
+
const state = saslReadyState();
|
|
967
|
+
const ctx = makeCtx(state, undefined, undefined, services);
|
|
968
|
+
|
|
969
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
970
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
971
|
+
|
|
972
|
+
expect(state.account).toBeUndefined();
|
|
973
|
+
expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
@@ -5,16 +5,20 @@ import {
|
|
|
5
5
|
timeReducer,
|
|
6
6
|
versionReducer,
|
|
7
7
|
} from '../../src/commands/server-info';
|
|
8
|
-
import { DEFAULT_SERVER_VERSION } from '../../src/config';
|
|
9
8
|
import { Effect } from '../../src/effects';
|
|
10
9
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
11
10
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
12
11
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
13
12
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
14
13
|
|
|
14
|
+
// Explicit version pinned in the fixture so the assertions below verify that
|
|
15
|
+
// the configured version is what reaches the wire — not a specific default.
|
|
16
|
+
const FIXTURE_VERSION = '9.9.9';
|
|
17
|
+
|
|
15
18
|
const baseServerConfig: ServerConfig = {
|
|
16
19
|
serverName: 'irc.example.com',
|
|
17
20
|
networkName: 'ExampleNet',
|
|
21
|
+
serverVersion: FIXTURE_VERSION,
|
|
18
22
|
maxChannelsPerUser: 30,
|
|
19
23
|
maxTargetsPerCommand: 10,
|
|
20
24
|
maxListEntries: 50,
|
|
@@ -71,7 +75,7 @@ describe('versionReducer', () => {
|
|
|
71
75
|
|
|
72
76
|
expect(out.effects).toEqual<EffectType[]>([
|
|
73
77
|
Effect.send('c1', [
|
|
74
|
-
L(`:irc.example.com 351 alice ${
|
|
78
|
+
L(`:irc.example.com 351 alice ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
|
|
75
79
|
]),
|
|
76
80
|
]);
|
|
77
81
|
});
|
|
@@ -93,7 +97,7 @@ describe('versionReducer', () => {
|
|
|
93
97
|
|
|
94
98
|
expect(out.effects[0]).toEqual<EffectType>(
|
|
95
99
|
Effect.send('c1', [
|
|
96
|
-
L(`:irc.example.com 351 * ${
|
|
100
|
+
L(`:irc.example.com 351 * ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
|
|
97
101
|
]),
|
|
98
102
|
);
|
|
99
103
|
});
|
|
@@ -106,7 +110,7 @@ describe('versionReducer', () => {
|
|
|
106
110
|
|
|
107
111
|
expect(out.effects[0]).toEqual<EffectType>(
|
|
108
112
|
Effect.send('c1', [
|
|
109
|
-
L(`:irc.example.com 351 alice ${
|
|
113
|
+
L(`:irc.example.com 351 alice ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
|
|
110
114
|
]),
|
|
111
115
|
);
|
|
112
116
|
});
|
|
@@ -234,7 +238,7 @@ describe('infoReducer', () => {
|
|
|
234
238
|
|
|
235
239
|
const lines = (out.effects[0] as { lines: RawLine[] }).lines;
|
|
236
240
|
expect(lines[0]).toEqual(
|
|
237
|
-
L(`:irc.example.com 371 alice :ServerlessIRCd - version ${
|
|
241
|
+
L(`:irc.example.com 371 alice :ServerlessIRCd - version ${FIXTURE_VERSION}`),
|
|
238
242
|
);
|
|
239
243
|
expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
|
|
240
244
|
expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CHANSERV_NICK } from '../../src/commands/chanserv';
|
|
3
|
+
import { HOSTSERV_NICK } from '../../src/commands/hostserv';
|
|
4
|
+
import { MEMOSERV_NICK } from '../../src/commands/memoserv';
|
|
5
|
+
import { NICKSERV_NICK } from '../../src/commands/nickserv';
|
|
6
|
+
import { OPERSERV_NICK } from '../../src/commands/operserv';
|
|
7
|
+
import { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from '../../src/commands/service-aliases';
|
|
8
|
+
|
|
9
|
+
describe('service-aliases — single source of truth for shortcut verbs', () => {
|
|
10
|
+
it('maps every Atheme/Anope shortcut verb to its canonical service nick', () => {
|
|
11
|
+
const expected: ReadonlyArray<[string, string]> = [
|
|
12
|
+
['NICKSERV', NICKSERV_NICK],
|
|
13
|
+
['NS', NICKSERV_NICK],
|
|
14
|
+
['CHANSERV', CHANSERV_NICK],
|
|
15
|
+
['CS', CHANSERV_NICK],
|
|
16
|
+
['HOSTSERV', HOSTSERV_NICK],
|
|
17
|
+
['HS', HOSTSERV_NICK],
|
|
18
|
+
['MEMOSERV', MEMOSERV_NICK],
|
|
19
|
+
['MS', MEMOSERV_NICK],
|
|
20
|
+
['OPERSERV', OPERSERV_NICK],
|
|
21
|
+
['OS', OPERSERV_NICK],
|
|
22
|
+
];
|
|
23
|
+
expect(SHORTCUT_TO_SERVICE.size).toBe(10);
|
|
24
|
+
for (const [alias, nick] of expected) {
|
|
25
|
+
expect(SHORTCUT_TO_SERVICE.get(alias)).toBe(nick);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keys every alias uppercased so the dispatcher can do a case-insensitive lookup', () => {
|
|
30
|
+
for (const alias of SHORTCUT_TO_SERVICE.keys()) {
|
|
31
|
+
expect(alias).toBe(alias.toUpperCase());
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('SERVICE_NICKS is the de-duplicated set of service nicks from the map values', () => {
|
|
36
|
+
const expected = Array.from(new Set(SHORTCUT_TO_SERVICE.values())).sort();
|
|
37
|
+
expect([...SERVICE_NICKS].sort()).toEqual(expected);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('every value in SHORTCUT_TO_SERVICE is one of the canonical *_NICK constants', () => {
|
|
41
|
+
const canonical = new Set<string>([
|
|
42
|
+
NICKSERV_NICK,
|
|
43
|
+
CHANSERV_NICK,
|
|
44
|
+
HOSTSERV_NICK,
|
|
45
|
+
MEMOSERV_NICK,
|
|
46
|
+
OPERSERV_NICK,
|
|
47
|
+
]);
|
|
48
|
+
for (const nick of SHORTCUT_TO_SERVICE.values()) {
|
|
49
|
+
expect(canonical.has(nick)).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -6,10 +6,10 @@ import {
|
|
|
6
6
|
EmptyMotdProvider,
|
|
7
7
|
FakeClock,
|
|
8
8
|
InMemoryMessageStore,
|
|
9
|
-
|
|
9
|
+
InMemoryServicesStore,
|
|
10
10
|
type MessageStore,
|
|
11
|
-
type ReadMarkerStore,
|
|
12
11
|
SequentialIdFactory,
|
|
12
|
+
type ServicesStore,
|
|
13
13
|
type StoredMessage,
|
|
14
14
|
} from '../../src/ports';
|
|
15
15
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
@@ -32,7 +32,7 @@ function makeCtx(
|
|
|
32
32
|
conn: ConnectionState,
|
|
33
33
|
clock = new FakeClock(1_000),
|
|
34
34
|
messages?: MessageStore,
|
|
35
|
-
|
|
35
|
+
services?: ServicesStore,
|
|
36
36
|
): Ctx {
|
|
37
37
|
return buildCtx({
|
|
38
38
|
serverConfig,
|
|
@@ -41,7 +41,7 @@ function makeCtx(
|
|
|
41
41
|
motd: EmptyMotdProvider,
|
|
42
42
|
connection: conn,
|
|
43
43
|
...(messages !== undefined ? { messages } : {}),
|
|
44
|
-
...(
|
|
44
|
+
...(services !== undefined ? { services } : {}),
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -551,18 +551,19 @@ describe('tagmsgChannelReducer — draft/typing broadcast', () => {
|
|
|
551
551
|
// IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<msgid>`
|
|
552
552
|
// marks `<msgid>` as the last read message for the sender's account in the
|
|
553
553
|
// target channel. The reducer (1) persists the marker via the bound
|
|
554
|
-
//
|
|
554
|
+
// ServicesStore keyed by (account, channel), (2) advances the connection's
|
|
555
555
|
// in-memory lastReadMarkers, and (3) fans the TAGMSG out — carrying the
|
|
556
556
|
// `+draft/read-marker` tag — to members that negotiated `draft/read-marker`
|
|
557
557
|
// OR `message-tags` (OR-semantics, matching the draft/typing precedent).
|
|
558
558
|
// ============================================================================
|
|
559
559
|
|
|
560
560
|
describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', () => {
|
|
561
|
-
it('emits
|
|
561
|
+
it('emits an account-scoped Broadcast (caps + account) for an identified originator', () => {
|
|
562
562
|
const chan = makeChan('#foo');
|
|
563
563
|
addMember(chan, 'c1', 'alice');
|
|
564
564
|
addMember(chan, 'c2', 'bob');
|
|
565
565
|
const conn = makeConn();
|
|
566
|
+
conn.account = 'alice';
|
|
566
567
|
const ctx = makeCtx(conn);
|
|
567
568
|
|
|
568
569
|
const out = tagmsgChannelReducer(
|
|
@@ -578,17 +579,54 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
578
579
|
lines: [L('@+draft/read-marker=m42 :alice!alice@example.com TAGMSG #foo')],
|
|
579
580
|
except: 'c1',
|
|
580
581
|
caps: ['message-tags', 'draft/read-marker'],
|
|
582
|
+
account: 'alice',
|
|
581
583
|
},
|
|
582
584
|
]);
|
|
583
585
|
});
|
|
584
586
|
|
|
585
|
-
it('
|
|
587
|
+
it('does NOT fan out a read-marker mark when the originator is unidentified (spec privacy)', () => {
|
|
588
|
+
const chan = makeChan('#foo');
|
|
589
|
+
addMember(chan, 'c1', 'alice');
|
|
590
|
+
addMember(chan, 'c2', 'bob');
|
|
591
|
+
const conn = makeConn(); // no account
|
|
592
|
+
const ctx = makeCtx(conn);
|
|
593
|
+
|
|
594
|
+
const out = tagmsgChannelReducer(
|
|
595
|
+
chan,
|
|
596
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm42' } },
|
|
597
|
+
ctx,
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
// Unidentified: the mark is session-local. No Broadcast reaches anyone.
|
|
601
|
+
expect(out.effects).toEqual([]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('still self-echoes for an unidentified originator when echo-message is negotiated', () => {
|
|
605
|
+
const chan = makeChan('#foo');
|
|
606
|
+
addMember(chan, 'c1', 'alice');
|
|
607
|
+
const conn = makeConn(); // no account
|
|
608
|
+
conn.caps.add('echo-message');
|
|
609
|
+
const ctx = makeCtx(conn);
|
|
610
|
+
|
|
611
|
+
const out = tagmsgChannelReducer(
|
|
612
|
+
chan,
|
|
613
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm3' } },
|
|
614
|
+
ctx,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// No Broadcast (unidentified), but the originator's own client still
|
|
618
|
+
// sees its mark reflected back via echo-message.
|
|
619
|
+
const line = L('@+draft/read-marker=m3 :alice!alice@example.com TAGMSG #foo');
|
|
620
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [line])]);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('persists the marker to the ServicesStore keyed by (account, channel)', () => {
|
|
586
624
|
const chan = makeChan('#foo');
|
|
587
625
|
addMember(chan, 'c1', 'alice');
|
|
588
626
|
const conn = makeConn();
|
|
589
627
|
conn.account = 'alice';
|
|
590
|
-
const
|
|
591
|
-
const ctx = makeCtx(conn, undefined, undefined,
|
|
628
|
+
const services = new InMemoryServicesStore();
|
|
629
|
+
const ctx = makeCtx(conn, undefined, undefined, services);
|
|
592
630
|
|
|
593
631
|
tagmsgChannelReducer(
|
|
594
632
|
chan,
|
|
@@ -596,7 +634,7 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
596
634
|
ctx,
|
|
597
635
|
);
|
|
598
636
|
|
|
599
|
-
expect(
|
|
637
|
+
expect(services.getLastReadMarker('alice', '#foo')).toBe('m7');
|
|
600
638
|
});
|
|
601
639
|
|
|
602
640
|
it('advances the connection in-memory lastReadMarkers', () => {
|
|
@@ -604,7 +642,7 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
604
642
|
addMember(chan, 'c1', 'alice');
|
|
605
643
|
const conn = makeConn();
|
|
606
644
|
conn.account = 'alice';
|
|
607
|
-
const ctx = makeCtx(conn, undefined, undefined, new
|
|
645
|
+
const ctx = makeCtx(conn, undefined, undefined, new InMemoryServicesStore());
|
|
608
646
|
|
|
609
647
|
tagmsgChannelReducer(
|
|
610
648
|
chan,
|
|
@@ -620,8 +658,8 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
620
658
|
addMember(chan, 'c1', 'alice');
|
|
621
659
|
const conn = makeConn();
|
|
622
660
|
conn.account = 'alice';
|
|
623
|
-
const
|
|
624
|
-
const ctx = makeCtx(conn, undefined, undefined,
|
|
661
|
+
const services = new InMemoryServicesStore();
|
|
662
|
+
const ctx = makeCtx(conn, undefined, undefined, services);
|
|
625
663
|
|
|
626
664
|
tagmsgChannelReducer(
|
|
627
665
|
chan,
|
|
@@ -634,15 +672,15 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
634
672
|
ctx,
|
|
635
673
|
);
|
|
636
674
|
|
|
637
|
-
expect(
|
|
675
|
+
expect(services.getLastReadMarker('alice', '#foo')).toBe('m2');
|
|
638
676
|
});
|
|
639
677
|
|
|
640
678
|
it('does NOT persist to the store when the connection has no account', () => {
|
|
641
679
|
const chan = makeChan('#foo');
|
|
642
680
|
addMember(chan, 'c1', 'alice');
|
|
643
681
|
const conn = makeConn(); // no account
|
|
644
|
-
const
|
|
645
|
-
const ctx = makeCtx(conn, undefined, undefined,
|
|
682
|
+
const services = new InMemoryServicesStore();
|
|
683
|
+
const ctx = makeCtx(conn, undefined, undefined, services);
|
|
646
684
|
|
|
647
685
|
tagmsgChannelReducer(
|
|
648
686
|
chan,
|
|
@@ -650,12 +688,12 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
650
688
|
ctx,
|
|
651
689
|
);
|
|
652
690
|
|
|
653
|
-
expect(
|
|
691
|
+
expect(services.listReadMarkers('alice')).toEqual([]);
|
|
654
692
|
// The in-memory marker still advances for the session.
|
|
655
693
|
expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
656
694
|
});
|
|
657
695
|
|
|
658
|
-
it('does NOT persist when no
|
|
696
|
+
it('does NOT persist when no ServicesStore is bound but still fans out (identified)', () => {
|
|
659
697
|
const chan = makeChan('#foo');
|
|
660
698
|
addMember(chan, 'c1', 'alice');
|
|
661
699
|
const conn = makeConn();
|
|
@@ -669,17 +707,17 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
669
707
|
);
|
|
670
708
|
|
|
671
709
|
expect(out.effects).toHaveLength(1);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
]);
|
|
710
|
+
const broadcast = out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>;
|
|
711
|
+
expect(broadcast.caps).toEqual(['message-tags', 'draft/read-marker']);
|
|
712
|
+
expect(broadcast.account).toBe('alice');
|
|
676
713
|
expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
677
714
|
});
|
|
678
715
|
|
|
679
|
-
it('excludes the sender unless echo-message is negotiated', () => {
|
|
716
|
+
it('excludes the sender unless echo-message is negotiated (identified)', () => {
|
|
680
717
|
const chan = makeChan('#foo');
|
|
681
718
|
addMember(chan, 'c1', 'alice');
|
|
682
719
|
const conn = makeConn();
|
|
720
|
+
conn.account = 'alice';
|
|
683
721
|
const ctx = makeCtx(conn);
|
|
684
722
|
|
|
685
723
|
const out = tagmsgChannelReducer(
|
|
@@ -692,10 +730,11 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
692
730
|
expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
|
|
693
731
|
});
|
|
694
732
|
|
|
695
|
-
it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated', () => {
|
|
733
|
+
it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated (identified)', () => {
|
|
696
734
|
const chan = makeChan('#foo');
|
|
697
735
|
addMember(chan, 'c1', 'alice');
|
|
698
736
|
const conn = makeConn();
|
|
737
|
+
conn.account = 'alice';
|
|
699
738
|
conn.caps.add('echo-message');
|
|
700
739
|
const ctx = makeCtx(conn);
|
|
701
740
|
|
|
@@ -713,18 +752,19 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
713
752
|
lines: [line],
|
|
714
753
|
except: 'c1',
|
|
715
754
|
caps: ['message-tags', 'draft/read-marker'],
|
|
755
|
+
account: 'alice',
|
|
716
756
|
},
|
|
717
757
|
Effect.send('c1', [line]),
|
|
718
758
|
]);
|
|
719
759
|
});
|
|
720
760
|
|
|
721
|
-
it('fans out a valueless +draft/read-marker tag but persists no msgid', () => {
|
|
761
|
+
it('fans out a valueless +draft/read-marker tag (identified) but persists no msgid', () => {
|
|
722
762
|
const chan = makeChan('#foo');
|
|
723
763
|
addMember(chan, 'c1', 'alice');
|
|
724
764
|
const conn = makeConn();
|
|
725
765
|
conn.account = 'alice';
|
|
726
|
-
const
|
|
727
|
-
const ctx = makeCtx(conn, undefined, undefined,
|
|
766
|
+
const services = new InMemoryServicesStore();
|
|
767
|
+
const ctx = makeCtx(conn, undefined, undefined, services);
|
|
728
768
|
|
|
729
769
|
const out = tagmsgChannelReducer(
|
|
730
770
|
chan,
|
|
@@ -732,13 +772,13 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
|
|
|
732
772
|
ctx,
|
|
733
773
|
);
|
|
734
774
|
|
|
735
|
-
// Broadcast still widens to draft/read-marker peers (tag is present)
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
775
|
+
// Broadcast still widens to draft/read-marker peers (tag is present) and
|
|
776
|
+
// is account-scoped for the identified originator...
|
|
777
|
+
const broadcast = out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>;
|
|
778
|
+
expect(broadcast.caps).toEqual(['message-tags', 'draft/read-marker']);
|
|
779
|
+
expect(broadcast.account).toBe('alice');
|
|
740
780
|
// ...but nothing is persisted (no msgid to store).
|
|
741
|
-
expect(
|
|
781
|
+
expect(services.listReadMarkers('alice')).toEqual([]);
|
|
742
782
|
expect(conn.lastReadMarkers?.get('#foo')).toBeUndefined();
|
|
743
783
|
});
|
|
744
784
|
});
|