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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from '@serverless-ircd/irc-core';
|
|
2
2
|
import { CapturingLogger, LogLevel } from '@serverless-ircd/irc-core';
|
|
3
3
|
import type { ChannelDelta, ConnId, RawLine } from '@serverless-ircd/irc-core';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { dispatch } from '../src/dispatch';
|
|
6
6
|
import type { IrcRuntime } from '../src/runtime';
|
|
7
7
|
|
|
@@ -251,6 +251,12 @@ describe('dispatch', () => {
|
|
|
251
251
|
|
|
252
252
|
interface MemberInfo {
|
|
253
253
|
caps: ReadonlySet<string>;
|
|
254
|
+
/**
|
|
255
|
+
* Optional SASL/NickServ account the member is identified as. When set,
|
|
256
|
+
* the fake runtime's `getConnectionInfo` surfaces it so account-scoped
|
|
257
|
+
* broadcast filtering (draft/read-marker privacy) can be exercised.
|
|
258
|
+
*/
|
|
259
|
+
account?: string;
|
|
254
260
|
}
|
|
255
261
|
|
|
256
262
|
/**
|
|
@@ -300,9 +306,11 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
|
|
|
300
306
|
wallops: false,
|
|
301
307
|
serverNotices: false,
|
|
302
308
|
tls: false,
|
|
309
|
+
registered: false,
|
|
303
310
|
},
|
|
304
311
|
lastSeen: 0,
|
|
305
312
|
connectedSince: 0,
|
|
313
|
+
...(info.account !== undefined ? { account: info.account } : {}),
|
|
306
314
|
};
|
|
307
315
|
},
|
|
308
316
|
async getConnection() {
|
|
@@ -323,6 +331,9 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
|
|
|
323
331
|
moderated: false,
|
|
324
332
|
secret: false,
|
|
325
333
|
private: false,
|
|
334
|
+
registered: false,
|
|
335
|
+
blockUnidentified: false,
|
|
336
|
+
moderatedIdent: false,
|
|
326
337
|
},
|
|
327
338
|
banMasks: new Set<string>(),
|
|
328
339
|
members: roster,
|
|
@@ -491,6 +502,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
491
502
|
wallops: false,
|
|
492
503
|
serverNotices: false,
|
|
493
504
|
tls: false,
|
|
505
|
+
registered: false,
|
|
494
506
|
},
|
|
495
507
|
lastSeen: 0,
|
|
496
508
|
connectedSince: 0,
|
|
@@ -516,6 +528,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
|
|
|
516
528
|
moderated: false,
|
|
517
529
|
secret: false,
|
|
518
530
|
private: false,
|
|
531
|
+
registered: false,
|
|
532
|
+
blockUnidentified: false,
|
|
533
|
+
moderatedIdent: false,
|
|
519
534
|
},
|
|
520
535
|
banMasks: new Set<string>(),
|
|
521
536
|
members: roster,
|
|
@@ -690,7 +705,183 @@ describe('dispatch — Broadcast caps (OR semantics)', () => {
|
|
|
690
705
|
});
|
|
691
706
|
|
|
692
707
|
// ===========================================================================
|
|
693
|
-
// dispatch — Broadcast
|
|
708
|
+
// dispatch — Broadcast account-scoped recipient filter (draft/read-marker)
|
|
709
|
+
//
|
|
710
|
+
// IRCv3 `draft/read-marker` privacy: a read marker is private to the
|
|
711
|
+
// originator's account and MUST NOT be disclosed to other users. The reducer
|
|
712
|
+
// annotates the mark TAGMSG Broadcast with `account`; dispatch restricts the
|
|
713
|
+
// recipient set to channel members authenticated as that account (case-
|
|
714
|
+
// insensitive), still honouring `except` and the OR-semantics `caps` filter.
|
|
715
|
+
// Unidentified peers (no account) never receive a mark bound to an account.
|
|
716
|
+
// ===========================================================================
|
|
717
|
+
|
|
718
|
+
describe('dispatch — Broadcast account-scoped filter (draft/read-marker)', () => {
|
|
719
|
+
it('delivers ONLY to same-account members; cross-account members are excluded', async () => {
|
|
720
|
+
const members = memberMap([
|
|
721
|
+
['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
|
|
722
|
+
['c2', { caps: new Set(['message-tags']), account: 'alice' }], // same account
|
|
723
|
+
['c3', { caps: new Set(['message-tags']), account: 'bob' }], // different account
|
|
724
|
+
]);
|
|
725
|
+
const { runtime, sends, broadcasts } = capAwareRuntime(members);
|
|
726
|
+
|
|
727
|
+
await dispatch(
|
|
728
|
+
[
|
|
729
|
+
Effect.broadcast(
|
|
730
|
+
'#foo',
|
|
731
|
+
[L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
|
|
732
|
+
'c1',
|
|
733
|
+
undefined,
|
|
734
|
+
undefined,
|
|
735
|
+
['message-tags', 'draft/read-marker'],
|
|
736
|
+
'alice',
|
|
737
|
+
),
|
|
738
|
+
],
|
|
739
|
+
runtime,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// No fast-path bulk broadcast: dispatch routes per-recipient.
|
|
743
|
+
expect(broadcasts).toHaveLength(0);
|
|
744
|
+
// Only c2 (same account) receives; c3 (different account) is excluded.
|
|
745
|
+
expect(sends.map((s) => s.to).sort()).toEqual(['c2']);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('excludes unidentified members (no account) from an account-scoped mark', async () => {
|
|
749
|
+
const members = memberMap([
|
|
750
|
+
['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
|
|
751
|
+
['c2', { caps: new Set(['message-tags']) }], // unidentified
|
|
752
|
+
]);
|
|
753
|
+
const { runtime, sends } = capAwareRuntime(members);
|
|
754
|
+
|
|
755
|
+
await dispatch(
|
|
756
|
+
[
|
|
757
|
+
Effect.broadcast(
|
|
758
|
+
'#foo',
|
|
759
|
+
[L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
|
|
760
|
+
'c1',
|
|
761
|
+
undefined,
|
|
762
|
+
undefined,
|
|
763
|
+
['message-tags', 'draft/read-marker'],
|
|
764
|
+
'alice',
|
|
765
|
+
),
|
|
766
|
+
],
|
|
767
|
+
runtime,
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
expect(sends).toHaveLength(0);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('matches the account case-insensitively (Alice vs alice)', async () => {
|
|
774
|
+
const members = memberMap([
|
|
775
|
+
['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
|
|
776
|
+
['c2', { caps: new Set(['message-tags']), account: 'ALICE' }], // same account, diff case
|
|
777
|
+
]);
|
|
778
|
+
const { runtime, sends } = capAwareRuntime(members);
|
|
779
|
+
|
|
780
|
+
await dispatch(
|
|
781
|
+
[
|
|
782
|
+
Effect.broadcast(
|
|
783
|
+
'#foo',
|
|
784
|
+
[L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
|
|
785
|
+
'c1',
|
|
786
|
+
undefined,
|
|
787
|
+
undefined,
|
|
788
|
+
['message-tags', 'draft/read-marker'],
|
|
789
|
+
'alice',
|
|
790
|
+
),
|
|
791
|
+
],
|
|
792
|
+
runtime,
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
expect(sends.map((s) => s.to)).toEqual(['c2']);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('still applies the OR-caps filter alongside the account filter', async () => {
|
|
799
|
+
const members = memberMap([
|
|
800
|
+
['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
|
|
801
|
+
// same account but no relevant cap — excluded by caps filter:
|
|
802
|
+
['c2', { caps: new Set<string>(), account: 'alice' }],
|
|
803
|
+
// same account, draft/read-marker cap — delivered:
|
|
804
|
+
['c3', { caps: new Set(['draft/read-marker']), account: 'alice' }],
|
|
805
|
+
]);
|
|
806
|
+
const { runtime, sends } = capAwareRuntime(members);
|
|
807
|
+
|
|
808
|
+
await dispatch(
|
|
809
|
+
[
|
|
810
|
+
Effect.broadcast(
|
|
811
|
+
'#foo',
|
|
812
|
+
[L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
|
|
813
|
+
'c1',
|
|
814
|
+
undefined,
|
|
815
|
+
undefined,
|
|
816
|
+
['message-tags', 'draft/read-marker'],
|
|
817
|
+
'alice',
|
|
818
|
+
),
|
|
819
|
+
],
|
|
820
|
+
runtime,
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
expect(sends.map((s) => s.to)).toEqual(['c3']);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('ignores the account filter when account is unset (channel-wide broadcast)', async () => {
|
|
827
|
+
const members = memberMap([
|
|
828
|
+
['c1', { caps: new Set(['message-tags']), account: 'alice' }],
|
|
829
|
+
['c2', { caps: new Set(['message-tags']), account: 'bob' }],
|
|
830
|
+
['c3', { caps: new Set(['message-tags']), account: 'carol' }],
|
|
831
|
+
]);
|
|
832
|
+
const { runtime, sends } = capAwareRuntime(members);
|
|
833
|
+
|
|
834
|
+
await dispatch(
|
|
835
|
+
[
|
|
836
|
+
Effect.broadcast(
|
|
837
|
+
'#foo',
|
|
838
|
+
[L('@+draft/typing=active :a!u@h TAGMSG #foo')],
|
|
839
|
+
'c1',
|
|
840
|
+
undefined,
|
|
841
|
+
undefined,
|
|
842
|
+
['message-tags', 'draft/typing'],
|
|
843
|
+
),
|
|
844
|
+
],
|
|
845
|
+
runtime,
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// No account filter → every other member receives regardless of account.
|
|
849
|
+
expect(sends.map((s) => s.to).sort()).toEqual(['c2', 'c3']);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('delivers to every same-account member regardless of caps when only account is set', async () => {
|
|
853
|
+
// account-only broadcast (no cap / caps): the recipient set is purely
|
|
854
|
+
// account-scoped, so caps play no role — every same-account member
|
|
855
|
+
// receives the line, even with an empty cap set.
|
|
856
|
+
const members = memberMap([
|
|
857
|
+
['c1', { caps: new Set<string>(), account: 'alice' }], // originator (except)
|
|
858
|
+
['c2', { caps: new Set<string>(), account: 'alice' }], // same account, no caps
|
|
859
|
+
['c3', { caps: new Set<string>(), account: 'bob' }], // different account
|
|
860
|
+
]);
|
|
861
|
+
const { runtime, sends, broadcasts } = capAwareRuntime(members);
|
|
862
|
+
|
|
863
|
+
await dispatch(
|
|
864
|
+
[
|
|
865
|
+
Effect.broadcast(
|
|
866
|
+
'#foo',
|
|
867
|
+
[L(':a!u@h TAGMSG #foo')],
|
|
868
|
+
'c1',
|
|
869
|
+
undefined,
|
|
870
|
+
undefined,
|
|
871
|
+
undefined,
|
|
872
|
+
'alice',
|
|
873
|
+
),
|
|
874
|
+
],
|
|
875
|
+
runtime,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
// No fast-path bulk broadcast: dispatch routes per-recipient.
|
|
879
|
+
expect(broadcasts).toHaveLength(0);
|
|
880
|
+
// c2 (same account, no caps) receives; c3 (different account) is excluded.
|
|
881
|
+
expect(sends.map((s) => s.to)).toEqual(['c2']);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
694
885
|
//
|
|
695
886
|
// Recipients that did NOT negotiate `message-tags` must never receive a line
|
|
696
887
|
// beginning with `@+<client-tag>`. The dispatch broadcast per-recipient path
|
|
@@ -875,3 +1066,80 @@ describe('dispatch — observability', () => {
|
|
|
875
1066
|
expect(errs[0]?.fields?.err).toBe('string-error');
|
|
876
1067
|
});
|
|
877
1068
|
});
|
|
1069
|
+
|
|
1070
|
+
describe('dispatch — EnforceNick', () => {
|
|
1071
|
+
it('disconnects immediately for the kill policy', async () => {
|
|
1072
|
+
const calls: RecordedCall[] = [];
|
|
1073
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'kill', 0)], fakeRuntime(calls));
|
|
1074
|
+
expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('is a no-op for the none policy (no runtime calls)', async () => {
|
|
1078
|
+
const calls: RecordedCall[] = [];
|
|
1079
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'none', 0)], fakeRuntime(calls));
|
|
1080
|
+
expect(calls).toEqual([]);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('schedules the disconnect after graceMs for ghost via the injected scheduler', async () => {
|
|
1084
|
+
const calls: RecordedCall[] = [];
|
|
1085
|
+
const scheduled: { ms: number }[] = [];
|
|
1086
|
+
let scheduledCallback: (() => void) | undefined;
|
|
1087
|
+
const scheduler = (cb: () => void, ms: number): void => {
|
|
1088
|
+
scheduled.push({ ms });
|
|
1089
|
+
scheduledCallback = cb;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 30_000)], fakeRuntime(calls), {
|
|
1093
|
+
scheduler,
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Disconnect must NOT have fired yet (still within the grace window).
|
|
1097
|
+
expect(calls).toEqual([]);
|
|
1098
|
+
expect(scheduled).toEqual([{ ms: 30_000 }]);
|
|
1099
|
+
expect(scheduledCallback).toBeTypeOf('function');
|
|
1100
|
+
|
|
1101
|
+
// Flush the grace timer → disconnect fires with the canonical reason.
|
|
1102
|
+
scheduledCallback?.();
|
|
1103
|
+
await Promise.resolve();
|
|
1104
|
+
|
|
1105
|
+
expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('disconnects immediately for ghost when graceMs is 0', async () => {
|
|
1109
|
+
const calls: RecordedCall[] = [];
|
|
1110
|
+
const scheduler = (): void => {
|
|
1111
|
+
// Should never be called when graceMs is 0.
|
|
1112
|
+
throw new Error('scheduler must not be invoked for graceMs=0');
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 0)], fakeRuntime(calls), {
|
|
1116
|
+
scheduler,
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it('still disconnects for ghost when no scheduler is supplied (setTimeout fallback)', async () => {
|
|
1123
|
+
// graceMs=0 takes the immediate path, so the missing scheduler is fine.
|
|
1124
|
+
const calls: RecordedCall[] = [];
|
|
1125
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 0)], fakeRuntime(calls));
|
|
1126
|
+
expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('uses the default setTimeout scheduler when no scheduler is injected (ghost with grace)', async () => {
|
|
1130
|
+
vi.useFakeTimers();
|
|
1131
|
+
try {
|
|
1132
|
+
const calls: RecordedCall[] = [];
|
|
1133
|
+
// dispatch resolves once the timer is scheduled; the disconnect is deferred.
|
|
1134
|
+
await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 30_000)], fakeRuntime(calls));
|
|
1135
|
+
expect(calls).toEqual([]);
|
|
1136
|
+
|
|
1137
|
+
// Flushing the fake timer fires the scheduled disconnect.
|
|
1138
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1139
|
+
|
|
1140
|
+
expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
|
|
1141
|
+
} finally {
|
|
1142
|
+
vi.useRealTimers();
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
@@ -114,16 +114,22 @@ describe('buildRoutedReducers', () => {
|
|
|
114
114
|
'away',
|
|
115
115
|
'cap',
|
|
116
116
|
'channelMode',
|
|
117
|
+
'chanserv',
|
|
118
|
+
'hostserv',
|
|
117
119
|
'info',
|
|
118
120
|
'invite',
|
|
119
121
|
'join',
|
|
120
122
|
'kick',
|
|
123
|
+
'markreadChannel',
|
|
121
124
|
'motd',
|
|
122
125
|
'names',
|
|
123
126
|
'nick',
|
|
127
|
+
'nickserv',
|
|
128
|
+
'memoserv',
|
|
124
129
|
'noticeChannel',
|
|
125
130
|
'noticeUser',
|
|
126
131
|
'oper',
|
|
132
|
+
'operserv',
|
|
127
133
|
'part',
|
|
128
134
|
'pass',
|
|
129
135
|
'ping',
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
FakeClock,
|
|
22
22
|
type RawLine,
|
|
23
23
|
SequentialIdFactory,
|
|
24
|
+
type ServerConfig,
|
|
24
25
|
StaticMotdProvider,
|
|
25
26
|
createConnection,
|
|
26
27
|
} from '@serverless-ircd/irc-core';
|
|
@@ -44,7 +45,14 @@ const POLL_MS = 10;
|
|
|
44
45
|
/** Default timeout for waitFor* implementations (ms). */
|
|
45
46
|
const DEFAULT_TIMEOUT_MS = 1000;
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Shared server config used by the harness. Hard-coded — tests assert on it.
|
|
50
|
+
*
|
|
51
|
+
* `serverPassword` is intentionally NOT set here so the default harness
|
|
52
|
+
* leaves the registration gate disabled and every existing scenario
|
|
53
|
+
* registers without `PASS`. Construct the harness with
|
|
54
|
+
* `{ serverPassword: '...' }` to opt a scenario into the gated path.
|
|
55
|
+
*/
|
|
48
56
|
export const HARNESS_SERVER_CONFIG = {
|
|
49
57
|
serverName: 'irc.example.com',
|
|
50
58
|
networkName: 'ExampleNet',
|
|
@@ -57,6 +65,17 @@ export const HARNESS_SERVER_CONFIG = {
|
|
|
57
65
|
quitMessage: 'Client Quit',
|
|
58
66
|
} as const;
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Optional harness configuration. `serverPassword` opts a scenario into
|
|
70
|
+
* the server-password gate: when set, every spawned client must supply
|
|
71
|
+
* the matching `PASS <value>` (or SASL-identify) before `001
|
|
72
|
+
* RPL_WELCOME`. Default unset → gate disabled (existing scenarios
|
|
73
|
+
* unchanged).
|
|
74
|
+
*/
|
|
75
|
+
export interface InMemoryHarnessOptions {
|
|
76
|
+
serverPassword?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
/**
|
|
61
80
|
* Default MOTD lines exposed to every spawned client. The registration
|
|
62
81
|
* welcome block renders these lines as `375`/`372`×N/`376` numerics, and
|
|
@@ -120,6 +139,7 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
120
139
|
private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
|
|
121
140
|
private readonly clients = new Map<ConnId, TrackedClient>();
|
|
122
141
|
private readonly transportKind: TransportKind;
|
|
142
|
+
private readonly serverConfig: ServerConfig;
|
|
123
143
|
private nextId = 0;
|
|
124
144
|
private closed = false;
|
|
125
145
|
|
|
@@ -130,9 +150,15 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
130
150
|
* {@link TcpByteStreamTransport} so chunk-boundary scenarios exercise
|
|
131
151
|
* real byte-stream framing. One transport instance per client (TCP
|
|
132
152
|
* transports own a per-connection partial-line buffer).
|
|
153
|
+
* @param opts Optional harness-wide config. `serverPassword` opts a
|
|
154
|
+
* scenario into the registration gate (default off).
|
|
133
155
|
*/
|
|
134
|
-
constructor(transportKind: TransportKind = 'ws') {
|
|
156
|
+
constructor(transportKind: TransportKind = 'ws', opts: InMemoryHarnessOptions = {}) {
|
|
135
157
|
this.transportKind = transportKind;
|
|
158
|
+
this.serverConfig =
|
|
159
|
+
opts.serverPassword === undefined
|
|
160
|
+
? HARNESS_SERVER_CONFIG
|
|
161
|
+
: { ...HARNESS_SERVER_CONFIG, serverPassword: opts.serverPassword };
|
|
136
162
|
}
|
|
137
163
|
|
|
138
164
|
async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
|
|
@@ -162,7 +188,7 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
162
188
|
state,
|
|
163
189
|
runtime: this.runtime,
|
|
164
190
|
channels: this.runtime,
|
|
165
|
-
serverConfig:
|
|
191
|
+
serverConfig: this.serverConfig,
|
|
166
192
|
clock: this.clock,
|
|
167
193
|
ids: this.ids,
|
|
168
194
|
motd: this.motd,
|
|
@@ -237,12 +263,13 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
237
263
|
}
|
|
238
264
|
|
|
239
265
|
async feed(chunk: string): Promise<void> {
|
|
240
|
-
/* istanbul ignore
|
|
266
|
+
/* istanbul ignore start -- defensive: bindActor is called by the
|
|
241
267
|
harness before the client is returned, so the unbound-actor branch
|
|
242
268
|
is unreachable through the public API. */
|
|
243
269
|
if (this.actor === undefined) {
|
|
244
270
|
throw new Error(`client ${this.id} has no bound actor`);
|
|
245
271
|
}
|
|
272
|
+
/* istanbul ignore stop */
|
|
246
273
|
await this.actor.receive(chunk);
|
|
247
274
|
}
|
|
248
275
|
|
|
@@ -279,16 +306,18 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
279
306
|
this.closed = true;
|
|
280
307
|
// Best-effort QUIT so peers see the part broadcast; the runtime's
|
|
281
308
|
// disconnect handler is wired by the harness to unregister.
|
|
309
|
+
/* istanbul ignore start -- defensive: spawnClient binds the actor
|
|
310
|
+
before returning the client, so `this.actor` is never undefined
|
|
311
|
+
here. The guard exists purely as a teardown safety net. */
|
|
282
312
|
if (this.actor !== undefined) {
|
|
283
313
|
try {
|
|
284
314
|
await this.actor.receive('QUIT :client closed\r\n');
|
|
285
|
-
/* istanbul ignore next -- defensive: receive swallows
|
|
286
|
-
every error internally; the catch is purely a teardown safety
|
|
287
|
-
net for catastrophic runtime failures. */
|
|
288
315
|
} catch {
|
|
289
|
-
// no-op
|
|
316
|
+
// no-op -- receive swallows every error internally; this catch is
|
|
317
|
+
// purely a teardown safety net for catastrophic runtime failures.
|
|
290
318
|
}
|
|
291
319
|
}
|
|
320
|
+
/* istanbul ignore stop */
|
|
292
321
|
}
|
|
293
322
|
}
|
|
294
323
|
|
|
@@ -182,7 +182,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
182
182
|
await alice.waitForNumeric('366');
|
|
183
183
|
const namesLine = alice.received.find((l) => numericOf(l) === '353');
|
|
184
184
|
expect(namesLine).toBeDefined();
|
|
185
|
-
|
|
185
|
+
// namesLine is still `string | undefined` to TS (expect() does not
|
|
186
|
+
// narrow), so we route it back through expect() rather than a
|
|
187
|
+
// non-null assertion — toContain fails loudly if it were undefined.
|
|
188
|
+
expect(namesLine).toContain('@alice');
|
|
186
189
|
await harness.close();
|
|
187
190
|
});
|
|
188
191
|
|
|
@@ -246,7 +249,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
246
249
|
await alice.send('JOIN #room');
|
|
247
250
|
await bob.send('JOIN #room');
|
|
248
251
|
await untilAliceSeesBobJoin(alice, '#room');
|
|
249
|
-
alice.received.
|
|
252
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
253
|
+
// the registration + JOIN burst makes the no-self-echo assertion
|
|
254
|
+
// below non-vacuous: it scans real traffic and confirms the
|
|
255
|
+
// sender never receives her own PRIVMSG among any of it.
|
|
250
256
|
bob.received.length = 0;
|
|
251
257
|
await alice.send('PRIVMSG #room :hello team');
|
|
252
258
|
await bob.waitForLine((l) => l.includes('PRIVMSG #room :hello team'));
|
|
@@ -453,7 +459,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
453
459
|
await alice.send('JOIN #notice');
|
|
454
460
|
await bob.send('JOIN #notice');
|
|
455
461
|
await untilAliceSeesBobJoin(alice, '#notice');
|
|
456
|
-
alice.received.
|
|
462
|
+
// NOTE: alice.received is intentionally NOT cleared here. Keeping
|
|
463
|
+
// the registration + JOIN burst makes the NOTICE-no-error filter
|
|
464
|
+
// below non-vacuous: it scans real numeric traffic (353/366/…)
|
|
465
|
+
// and confirms none of it is a NOTICE error code.
|
|
457
466
|
bob.received.length = 0;
|
|
458
467
|
await alice.send('NOTICE #notice :quiet note');
|
|
459
468
|
await bob.waitForLine((l) => l.includes('NOTICE #notice :quiet note'));
|
|
@@ -469,13 +478,19 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
|
|
|
469
478
|
await harness.close();
|
|
470
479
|
});
|
|
471
480
|
|
|
472
|
-
it('scenario 27: PASS after registration is
|
|
481
|
+
it('scenario 27: bare PASS after registration is a silent no-op (no 462)', async () => {
|
|
473
482
|
harness = await factory.create();
|
|
474
483
|
const c = await harness.spawnClient({ nick: 'alice' });
|
|
475
484
|
await c.waitForNumeric('001');
|
|
476
485
|
c.received.length = 0;
|
|
477
|
-
|
|
478
|
-
|
|
486
|
+
// A bare server-password PASS is meaningless once registration has
|
|
487
|
+
// completed: it must be ignored (no 462, no disconnect). Send a
|
|
488
|
+
// PING in the same frame so ordering is guaranteed — by the time
|
|
489
|
+
// the PONG arrives, any 462 from the PASS would already be queued.
|
|
490
|
+
await c.send('PASS hunter2\r\nPING :post-pass');
|
|
491
|
+
await c.waitForLine((l) => l === 'PONG :post-pass');
|
|
492
|
+
const errAlreadyRegistered = c.received.filter((l) => numericOf(l) === '462');
|
|
493
|
+
expect(errAlreadyRegistered).toEqual([]);
|
|
479
494
|
await harness.close();
|
|
480
495
|
});
|
|
481
496
|
|
|
@@ -191,6 +191,25 @@ describe('InMemoryClientHarness.waitForNumeric', () => {
|
|
|
191
191
|
expect(result).toContain('004');
|
|
192
192
|
await h.close();
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
it('ignores received lines that carry no :server prefix when scanning', async () => {
|
|
196
|
+
// A PONG reply is emitted without a `:server` prefix (`PONG :tok`, not
|
|
197
|
+
// `:srv PONG :tok`). waitForNumeric must scan such a line without
|
|
198
|
+
// false-matching: numericEquals takes its no-prefix branch (start=0),
|
|
199
|
+
// rejects the line, and keeps scanning until the real numeric lands.
|
|
200
|
+
const h = new InMemoryHarness();
|
|
201
|
+
const c = await h.spawnClient({ nick: 'alice' });
|
|
202
|
+
await c.waitForNumeric('001');
|
|
203
|
+
// Isolate a bare (no-prefix) line first, then drive a prefixed numeric
|
|
204
|
+
// after it, so the scan MUST walk past the PONG to reach 375.
|
|
205
|
+
c.received.length = 0;
|
|
206
|
+
await c.send('PING :bare');
|
|
207
|
+
await c.waitForLine((l) => l === 'PONG :bare');
|
|
208
|
+
await c.send('MOTD');
|
|
209
|
+
const result = await c.waitForNumeric('375');
|
|
210
|
+
expect(result).toContain('375');
|
|
211
|
+
await h.close();
|
|
212
|
+
});
|
|
194
213
|
});
|
|
195
214
|
|
|
196
215
|
describe('InMemoryClientHarness.close', () => {
|
|
@@ -228,3 +247,35 @@ describe('InMemoryClientHarness.close', () => {
|
|
|
228
247
|
await h.close();
|
|
229
248
|
});
|
|
230
249
|
});
|
|
250
|
+
|
|
251
|
+
describe('InMemoryHarness — server-password gate opt-in', () => {
|
|
252
|
+
it('rejects registration with 464 + disconnect when serverPassword is set and no PASS is sent', async () => {
|
|
253
|
+
const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
|
|
254
|
+
const c = await h.spawnClient();
|
|
255
|
+
await c.send('NICK alice');
|
|
256
|
+
await c.send('USER alice 0 * :alice');
|
|
257
|
+
// No PASS → gate fires: 464 then the connection is torn down.
|
|
258
|
+
const line464 = await c.waitForNumeric('464');
|
|
259
|
+
expect(line464).toMatch(/464 alice :Password mismatch/u);
|
|
260
|
+
await h.close();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('completes registration when PASS matches the configured serverPassword', async () => {
|
|
264
|
+
const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
|
|
265
|
+
const c = await h.spawnClient();
|
|
266
|
+
await c.send('PASS s3cret');
|
|
267
|
+
await c.send('NICK bob');
|
|
268
|
+
await c.send('USER bob 0 * :bob');
|
|
269
|
+
await c.waitForNumeric('001');
|
|
270
|
+
await h.close();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('leaves the gate disabled by default (no serverPassword option)', async () => {
|
|
274
|
+
const h = new InMemoryHarness();
|
|
275
|
+
const c = await h.spawnClient();
|
|
276
|
+
await c.send('NICK carol');
|
|
277
|
+
await c.send('USER carol 0 * :carol');
|
|
278
|
+
await c.waitForNumeric('001');
|
|
279
|
+
await h.close();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -7,7 +7,12 @@ export default defineConfig({
|
|
|
7
7
|
provider: 'v8',
|
|
8
8
|
all: false,
|
|
9
9
|
include: ['src/**/*.ts'],
|
|
10
|
-
|
|
10
|
+
// harness.ts exports types/interfaces only — it compiles to a bare
|
|
11
|
+
// `export {}` with zero runtime statements, so v8 reports it as a
|
|
12
|
+
// 0/0 (0%-statements) file that drags the file list down. Excluding
|
|
13
|
+
// it keeps the report honest: only modules with runtime code are
|
|
14
|
+
// scored. index.ts is excluded as the pure re-export barrel.
|
|
15
|
+
exclude: ['src/**/index.ts', 'src/harness.ts'],
|
|
11
16
|
reporter: ['text', 'html', 'json-summary'],
|
|
12
17
|
thresholds: {
|
|
13
18
|
lines: 90,
|