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
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
EmptyMotdProvider,
|
|
7
7
|
FakeClock,
|
|
8
8
|
InMemoryMessageStore,
|
|
9
|
+
InMemoryServicesStore,
|
|
9
10
|
type MessageStore,
|
|
10
11
|
SequentialIdFactory,
|
|
12
|
+
type ServicesStore,
|
|
11
13
|
type StoredMessage,
|
|
12
14
|
} from '../../src/ports';
|
|
13
15
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
@@ -30,6 +32,7 @@ function makeCtx(
|
|
|
30
32
|
conn: ConnectionState,
|
|
31
33
|
clock = new FakeClock(1_000),
|
|
32
34
|
messages?: MessageStore,
|
|
35
|
+
services?: ServicesStore,
|
|
33
36
|
): Ctx {
|
|
34
37
|
return buildCtx({
|
|
35
38
|
serverConfig,
|
|
@@ -38,6 +41,7 @@ function makeCtx(
|
|
|
38
41
|
motd: EmptyMotdProvider,
|
|
39
42
|
connection: conn,
|
|
40
43
|
...(messages !== undefined ? { messages } : {}),
|
|
44
|
+
...(services !== undefined ? { services } : {}),
|
|
41
45
|
});
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -126,7 +130,7 @@ describe('joinReducer — success', () => {
|
|
|
126
130
|
|
|
127
131
|
expect(out.effects).toEqual<EffectType[]>([
|
|
128
132
|
Effect.applyChannelDelta('#foo', {
|
|
129
|
-
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
|
|
133
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true, voice: false }],
|
|
130
134
|
}),
|
|
131
135
|
Effect.broadcast(
|
|
132
136
|
'#foo',
|
|
@@ -929,3 +933,570 @@ describe('joinReducer — chathistory auto-playback', () => {
|
|
|
929
933
|
expect(conn.lastReadMarkers?.get('#foo')).toBe('m5');
|
|
930
934
|
});
|
|
931
935
|
});
|
|
936
|
+
|
|
937
|
+
// ============================================================================
|
|
938
|
+
// joinReducer — ChanServ +R (block unidentified join)
|
|
939
|
+
// ============================================================================
|
|
940
|
+
|
|
941
|
+
describe('joinReducer — +R (block unidentified join)', () => {
|
|
942
|
+
it('rejects an unidentified joiner with 519 ERR_ALLMUSTREGISTER', () => {
|
|
943
|
+
const chan = makeChan('#foo');
|
|
944
|
+
chan.modes.blockUnidentified = true;
|
|
945
|
+
const conn = makeConn('c1', 'guest');
|
|
946
|
+
// guest has no account set
|
|
947
|
+
const ctx = makeCtx(conn);
|
|
948
|
+
|
|
949
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
950
|
+
|
|
951
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
952
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
953
|
+
expect(texts).toContain(
|
|
954
|
+
':irc.example.com 519 guest #foo :You must register with NickServ to join this channel',
|
|
955
|
+
);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('admits an identified joiner (account set)', () => {
|
|
959
|
+
const chan = makeChan('#foo');
|
|
960
|
+
chan.modes.blockUnidentified = true;
|
|
961
|
+
const conn = makeConn('c1', 'alice');
|
|
962
|
+
conn.account = 'alice';
|
|
963
|
+
const ctx = makeCtx(conn);
|
|
964
|
+
|
|
965
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
966
|
+
|
|
967
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ============================================================================
|
|
972
|
+
// joinReducer — ChanServ KEEPTOPIC restore
|
|
973
|
+
// ============================================================================
|
|
974
|
+
|
|
975
|
+
describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
|
|
976
|
+
it('restores the persisted topic on the first join after empty-recreate', () => {
|
|
977
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
978
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
979
|
+
services.registerChannel('#foo', 'alice');
|
|
980
|
+
services.setChannelKeepTopic('#foo', true);
|
|
981
|
+
services.setChannelTopic('#foo', {
|
|
982
|
+
text: 'remembered topic',
|
|
983
|
+
setter: 'bob!bob@example.org',
|
|
984
|
+
setAt: 1_000,
|
|
985
|
+
});
|
|
986
|
+
const chan = makeChan('#foo');
|
|
987
|
+
// First joiner: members is empty.
|
|
988
|
+
expect(chan.members.size).toBe(0);
|
|
989
|
+
const conn = makeConn('c1', 'alice');
|
|
990
|
+
conn.account = 'alice';
|
|
991
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
992
|
+
|
|
993
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
994
|
+
|
|
995
|
+
expect(out.state.topic?.text).toBe('remembered topic');
|
|
996
|
+
expect(out.state.topic?.setter).toBe('bob!bob@example.org');
|
|
997
|
+
expect(out.state.topic?.setAt).toBe(1_000);
|
|
998
|
+
// The 332/333 numerics are emitted to the joiner.
|
|
999
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1000
|
+
expect(texts).toContain(':irc.example.com 332 alice #foo :remembered topic');
|
|
1001
|
+
expect(
|
|
1002
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 alice #foo bob!bob@example.org 1000')),
|
|
1003
|
+
).toBe(true);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('does not restore a topic when the channel has existing members', () => {
|
|
1007
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1008
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1009
|
+
services.registerChannel('#foo', 'alice');
|
|
1010
|
+
services.setChannelKeepTopic('#foo', true);
|
|
1011
|
+
services.setChannelTopic('#foo', { text: 'stale', setter: 's', setAt: 0 });
|
|
1012
|
+
const chan = makeChan('#foo');
|
|
1013
|
+
// Pre-existing member.
|
|
1014
|
+
chan.members.set('c0', { conn: 'c0', nick: 'bob', op: true, voice: false });
|
|
1015
|
+
const conn = makeConn('c1', 'alice');
|
|
1016
|
+
conn.account = 'alice';
|
|
1017
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1018
|
+
|
|
1019
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1020
|
+
|
|
1021
|
+
expect(out.state.topic).toBeUndefined();
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('does not restore a topic when no services store is bound', () => {
|
|
1025
|
+
const chan = makeChan('#foo');
|
|
1026
|
+
const conn = makeConn('c1', 'alice');
|
|
1027
|
+
const ctx = makeCtx(conn);
|
|
1028
|
+
|
|
1029
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1030
|
+
|
|
1031
|
+
expect(out.state.topic).toBeUndefined();
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('does not restore a topic when KEEPTOPIC is off', () => {
|
|
1035
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1036
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1037
|
+
services.registerChannel('#foo', 'alice');
|
|
1038
|
+
// keepTopic defaults to false; do not enable it.
|
|
1039
|
+
services.setChannelTopic('#foo', { text: 'nope', setter: 's', setAt: 0 });
|
|
1040
|
+
const chan = makeChan('#foo');
|
|
1041
|
+
const conn = makeConn('c1', 'alice');
|
|
1042
|
+
conn.account = 'alice';
|
|
1043
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1044
|
+
|
|
1045
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1046
|
+
|
|
1047
|
+
expect(out.state.topic).toBeUndefined();
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('does not restore when KEEPTOPIC is on but no topic snapshot is stored', () => {
|
|
1051
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1052
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1053
|
+
services.registerChannel('#foo', 'alice');
|
|
1054
|
+
services.setChannelKeepTopic('#foo', true);
|
|
1055
|
+
// No setChannelTopic call.
|
|
1056
|
+
const chan = makeChan('#foo');
|
|
1057
|
+
const conn = makeConn('c1', 'alice');
|
|
1058
|
+
conn.account = 'alice';
|
|
1059
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1060
|
+
|
|
1061
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1062
|
+
|
|
1063
|
+
expect(out.state.topic).toBeUndefined();
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
// joinReducer — TOPIC numerics (332/333) on JOIN
|
|
1069
|
+
// ============================================================================
|
|
1070
|
+
//
|
|
1071
|
+
// RFC 2812 §3.2.1: after a successful JOIN the server sends 332 RPL_TOPIC
|
|
1072
|
+
// and 333 RPL_TOPICWHOTIME whenever a topic is set, regardless of how the
|
|
1073
|
+
// topic reached the authoritative ChannelState (KEEPTOPIC restore, a
|
|
1074
|
+
// deploy snapshot, or an earlier joiner). These tests pin the behaviour
|
|
1075
|
+
// that was previously missing for every non-KEEPTOPIC-restore code path.
|
|
1076
|
+
// ============================================================================
|
|
1077
|
+
|
|
1078
|
+
describe('joinReducer — TOPIC numerics on JOIN', () => {
|
|
1079
|
+
it('emits 332/333 to the joiner when state.topic is already set (deploy snapshot)', () => {
|
|
1080
|
+
const chan = makeChan('#foo');
|
|
1081
|
+
// Simulate a topic that survived a deploy in storage and was loaded
|
|
1082
|
+
// onto the authoritative ChannelState before this JOIN (empty roster,
|
|
1083
|
+
// no KEEPTOPIC restore path taken).
|
|
1084
|
+
chan.topic = { text: 'persisted topic', setter: 'alice!alice@example.com', setAt: 5_000 };
|
|
1085
|
+
const conn = makeConn('c1', 'bob');
|
|
1086
|
+
const ctx = makeCtx(conn);
|
|
1087
|
+
|
|
1088
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1089
|
+
|
|
1090
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1091
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :persisted topic');
|
|
1092
|
+
expect(
|
|
1093
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 5000')),
|
|
1094
|
+
).toBe(true);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('emits 332/333 to a second joiner when the topic was set by an earlier joiner', () => {
|
|
1098
|
+
const chan = makeChan('#foo');
|
|
1099
|
+
addMember(chan, 'c0', 'alice', true);
|
|
1100
|
+
// Topic set by an earlier joiner on a non-empty channel.
|
|
1101
|
+
chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 1_000 };
|
|
1102
|
+
const conn = makeConn('c1', 'bob');
|
|
1103
|
+
const ctx = makeCtx(conn);
|
|
1104
|
+
|
|
1105
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1106
|
+
|
|
1107
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1108
|
+
expect(texts).toContain(':irc.example.com 332 bob #foo :hello world');
|
|
1109
|
+
expect(
|
|
1110
|
+
texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 1000')),
|
|
1111
|
+
).toBe(true);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('emits 332/333 after the 353/366 NAMES list (existing emission order)', () => {
|
|
1115
|
+
const chan = makeChan('#foo');
|
|
1116
|
+
chan.topic = { text: 't', setter: 's', setAt: 1 };
|
|
1117
|
+
const conn = makeConn('c1', 'bob');
|
|
1118
|
+
const ctx = makeCtx(conn);
|
|
1119
|
+
|
|
1120
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1121
|
+
|
|
1122
|
+
const send = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1123
|
+
const idx353 = send.findIndex((t) => t.includes(' 353 '));
|
|
1124
|
+
const idx366 = send.findIndex((t) => t.includes(' 366 '));
|
|
1125
|
+
const idx332 = send.findIndex((t) => t.includes(' 332 '));
|
|
1126
|
+
expect(idx353).toBeGreaterThanOrEqual(0);
|
|
1127
|
+
expect(idx366).toBeGreaterThan(idx353);
|
|
1128
|
+
expect(idx332).toBeGreaterThan(idx366);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('does not emit 332/333 when no topic is set', () => {
|
|
1132
|
+
const chan = makeChan('#foo');
|
|
1133
|
+
const conn = makeConn();
|
|
1134
|
+
const ctx = makeCtx(conn);
|
|
1135
|
+
|
|
1136
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1137
|
+
|
|
1138
|
+
const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
|
|
1139
|
+
expect(texts.some((t) => t.includes(' 332 '))).toBe(false);
|
|
1140
|
+
expect(texts.some((t) => t.includes(' 333 '))).toBe(false);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// ============================================================================
|
|
1145
|
+
// joinReducer — ChanServ ACCESS auto-op / auto-voice
|
|
1146
|
+
// ============================================================================
|
|
1147
|
+
|
|
1148
|
+
describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
|
|
1149
|
+
it('AOP accounts are auto-opped on JOIN', () => {
|
|
1150
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1151
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1152
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1153
|
+
services.registerChannel('#foo', 'alice');
|
|
1154
|
+
// bob holds the AOP default level.
|
|
1155
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1156
|
+
const chan = makeChan('#foo');
|
|
1157
|
+
// Pre-existing op so the founder-first-op path does not interfere.
|
|
1158
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1159
|
+
const conn = makeConn('c1', 'bob');
|
|
1160
|
+
conn.account = 'bob';
|
|
1161
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1162
|
+
|
|
1163
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1164
|
+
|
|
1165
|
+
// The authoritative roster reflects the auto-op.
|
|
1166
|
+
const entry = out.state.members.get('c1');
|
|
1167
|
+
expect(entry?.op).toBe(true);
|
|
1168
|
+
// ChanServ broadcasts `:ChanServ MODE #foo +o bob` so other clients update.
|
|
1169
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1170
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1171
|
+
);
|
|
1172
|
+
expect(broadcasts.some((t) => t.includes('MODE #foo +o bob'))).toBe(true);
|
|
1173
|
+
// The joiner's roster mirror also carries op.
|
|
1174
|
+
const applyDeltas = out.effects.filter((e) => e.tag === 'ApplyChannelDelta');
|
|
1175
|
+
expect(applyDeltas.length).toBeGreaterThan(0);
|
|
1176
|
+
for (const e of applyDeltas) {
|
|
1177
|
+
if (e.tag === 'ApplyChannelDelta') {
|
|
1178
|
+
const added = (e.delta.memberships ?? []).find((m) => m.conn === 'c1');
|
|
1179
|
+
expect(added?.op).toBe(true);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('VOP accounts are auto-voiced on JOIN', () => {
|
|
1185
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1186
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1187
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1188
|
+
services.registerChannel('#foo', 'alice');
|
|
1189
|
+
services.setChannelAccess('#foo', 'bob', 3);
|
|
1190
|
+
const chan = makeChan('#foo');
|
|
1191
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1192
|
+
const conn = makeConn('c1', 'bob');
|
|
1193
|
+
conn.account = 'bob';
|
|
1194
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1195
|
+
|
|
1196
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1197
|
+
|
|
1198
|
+
const entry = out.state.members.get('c1');
|
|
1199
|
+
expect(entry?.op).toBe(false);
|
|
1200
|
+
expect(entry?.voice).toBe(true);
|
|
1201
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1202
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1203
|
+
);
|
|
1204
|
+
expect(broadcasts.some((t) => t.includes('MODE #foo +v bob'))).toBe(true);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it('AOP-level accounts are also voiced (op supersedes voice but the +v is not emitted)', () => {
|
|
1208
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1209
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1210
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1211
|
+
services.registerChannel('#foo', 'alice');
|
|
1212
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1213
|
+
const chan = makeChan('#foo');
|
|
1214
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1215
|
+
const conn = makeConn('c1', 'bob');
|
|
1216
|
+
conn.account = 'bob';
|
|
1217
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1218
|
+
|
|
1219
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1220
|
+
|
|
1221
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1222
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1223
|
+
);
|
|
1224
|
+
// +o emitted; +v is not (op already grants voice speak rights).
|
|
1225
|
+
expect(broadcasts.some((t) => t.includes('MODE #foo +o bob'))).toBe(true);
|
|
1226
|
+
expect(broadcasts.some((t) => t.includes('+v'))).toBe(false);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('accounts with no access entry get no auto prefix', () => {
|
|
1230
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1231
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1232
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1233
|
+
services.registerChannel('#foo', 'alice');
|
|
1234
|
+
const chan = makeChan('#foo');
|
|
1235
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1236
|
+
const conn = makeConn('c1', 'bob');
|
|
1237
|
+
conn.account = 'bob';
|
|
1238
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1239
|
+
|
|
1240
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1241
|
+
|
|
1242
|
+
const entry = out.state.members.get('c1');
|
|
1243
|
+
expect(entry?.op).toBe(false);
|
|
1244
|
+
expect(entry?.voice).toBe(false);
|
|
1245
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1246
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1247
|
+
);
|
|
1248
|
+
expect(broadcasts.some((t) => t.includes('MODE #foo'))).toBe(false);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it('unidentified joiners get no auto prefix even with an entry', () => {
|
|
1252
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1253
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1254
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1255
|
+
services.registerChannel('#foo', 'alice');
|
|
1256
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1257
|
+
const chan = makeChan('#foo');
|
|
1258
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1259
|
+
const conn = makeConn('c1', 'bob');
|
|
1260
|
+
// No conn.account set.
|
|
1261
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1262
|
+
|
|
1263
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1264
|
+
|
|
1265
|
+
const entry = out.state.members.get('c1');
|
|
1266
|
+
expect(entry?.op).toBe(false);
|
|
1267
|
+
expect(entry?.voice).toBe(false);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it('LEVELS SET AUTOOP raises the auto-op threshold', () => {
|
|
1271
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1272
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1273
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1274
|
+
services.registerChannel('#foo', 'alice');
|
|
1275
|
+
// bob holds AOP (5) but the founder raised AUTOOP to 7.
|
|
1276
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1277
|
+
services.setChannelLevel('#foo', 'AUTOOP', 7);
|
|
1278
|
+
const chan = makeChan('#foo');
|
|
1279
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1280
|
+
const conn = makeConn('c1', 'bob');
|
|
1281
|
+
conn.account = 'bob';
|
|
1282
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1283
|
+
|
|
1284
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1285
|
+
|
|
1286
|
+
const entry = out.state.members.get('c1');
|
|
1287
|
+
expect(entry?.op).toBe(false);
|
|
1288
|
+
expect(entry?.voice).toBe(true);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('LEVELS SET AUTOVOICE raises the auto-voice threshold', () => {
|
|
1292
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1293
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1294
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1295
|
+
services.registerChannel('#foo', 'alice');
|
|
1296
|
+
// bob holds level 3 but the founder raised AUTOVOICE to 5.
|
|
1297
|
+
services.setChannelAccess('#foo', 'bob', 3);
|
|
1298
|
+
services.setChannelLevel('#foo', 'AUTOVOICE', 5);
|
|
1299
|
+
const chan = makeChan('#foo');
|
|
1300
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1301
|
+
const conn = makeConn('c1', 'bob');
|
|
1302
|
+
conn.account = 'bob';
|
|
1303
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1304
|
+
|
|
1305
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1306
|
+
|
|
1307
|
+
const entry = out.state.members.get('c1');
|
|
1308
|
+
expect(entry?.op).toBe(false);
|
|
1309
|
+
expect(entry?.voice).toBe(false);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it('the founder is auto-opped regardless of the access list', () => {
|
|
1313
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1314
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1315
|
+
services.registerChannel('#foo', 'alice');
|
|
1316
|
+
const chan = makeChan('#foo');
|
|
1317
|
+
chan.members.set('c0', { conn: 'c0', nick: 'someone', op: true, voice: false });
|
|
1318
|
+
const conn = makeConn('c1', 'alice');
|
|
1319
|
+
conn.account = 'alice';
|
|
1320
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1321
|
+
|
|
1322
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1323
|
+
|
|
1324
|
+
const entry = out.state.members.get('c1');
|
|
1325
|
+
expect(entry?.op).toBe(true);
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it('the first joiner (channel empty) keeps the RFC 2812 founder-op and skips ChanServ MODE', () => {
|
|
1329
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1330
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1331
|
+
services.registerChannel('#foo', 'alice');
|
|
1332
|
+
const chan = makeChan('#foo');
|
|
1333
|
+
expect(chan.members.size).toBe(0);
|
|
1334
|
+
const conn = makeConn('c1', 'alice');
|
|
1335
|
+
conn.account = 'alice';
|
|
1336
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1337
|
+
|
|
1338
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1339
|
+
|
|
1340
|
+
const entry = out.state.members.get('c1');
|
|
1341
|
+
expect(entry?.op).toBe(true);
|
|
1342
|
+
// The first-op path emits no ChanServ MODE broadcast.
|
|
1343
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1344
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1345
|
+
);
|
|
1346
|
+
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
it('does not auto-op the first joiner of an empty registered channel when off the access list', () => {
|
|
1350
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1351
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1352
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1353
|
+
services.registerChannel('#foo', 'alice');
|
|
1354
|
+
const chan = makeChan('#foo');
|
|
1355
|
+
expect(chan.members.size).toBe(0);
|
|
1356
|
+
const conn = makeConn('c1', 'bob');
|
|
1357
|
+
conn.account = 'bob';
|
|
1358
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1359
|
+
|
|
1360
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1361
|
+
|
|
1362
|
+
const entry = out.state.members.get('c1');
|
|
1363
|
+
expect(entry?.op).toBe(false);
|
|
1364
|
+
expect(entry?.voice).toBe(false);
|
|
1365
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1366
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1367
|
+
);
|
|
1368
|
+
expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
|
|
1369
|
+
expect(broadcasts.some((t) => t.includes('+o bob'))).toBe(false);
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('auto-ops the first joiner of an empty registered channel when they hold AUTOOP-level access', () => {
|
|
1373
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1374
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1375
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1376
|
+
services.registerChannel('#foo', 'alice');
|
|
1377
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1378
|
+
const chan = makeChan('#foo');
|
|
1379
|
+
expect(chan.members.size).toBe(0);
|
|
1380
|
+
const conn = makeConn('c1', 'bob');
|
|
1381
|
+
conn.account = 'bob';
|
|
1382
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1383
|
+
|
|
1384
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1385
|
+
|
|
1386
|
+
const entry = out.state.members.get('c1');
|
|
1387
|
+
expect(entry?.op).toBe(true);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('does not auto-op an unidentified first joiner of an empty registered channel', () => {
|
|
1391
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1392
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1393
|
+
services.registerChannel('#foo', 'alice');
|
|
1394
|
+
const chan = makeChan('#foo');
|
|
1395
|
+
expect(chan.members.size).toBe(0);
|
|
1396
|
+
const conn = makeConn('c1', 'bob');
|
|
1397
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1398
|
+
|
|
1399
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1400
|
+
|
|
1401
|
+
const entry = out.state.members.get('c1');
|
|
1402
|
+
expect(entry?.op).toBe(false);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('still auto-ops the founder first-joining an empty registered channel', () => {
|
|
1406
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1407
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1408
|
+
services.registerChannel('#foo', 'alice');
|
|
1409
|
+
const chan = makeChan('#foo');
|
|
1410
|
+
expect(chan.members.size).toBe(0);
|
|
1411
|
+
const conn = makeConn('c1', 'alice');
|
|
1412
|
+
conn.account = 'alice';
|
|
1413
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1414
|
+
|
|
1415
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1416
|
+
|
|
1417
|
+
const entry = out.state.members.get('c1');
|
|
1418
|
+
expect(entry?.op).toBe(true);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('auto-ops the first joiner of an empty channel when services are bound but the channel is unregistered', () => {
|
|
1422
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1423
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1424
|
+
const chan = makeChan('#foo');
|
|
1425
|
+
expect(chan.members.size).toBe(0);
|
|
1426
|
+
const conn = makeConn('c1', 'bob');
|
|
1427
|
+
conn.account = 'bob';
|
|
1428
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1429
|
+
|
|
1430
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1431
|
+
|
|
1432
|
+
const entry = out.state.members.get('c1');
|
|
1433
|
+
expect(entry?.op).toBe(true);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('auto-ops the first joiner of an empty channel when no services store is bound', () => {
|
|
1437
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1438
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1439
|
+
const chan = makeChan('#foo');
|
|
1440
|
+
expect(chan.members.size).toBe(0);
|
|
1441
|
+
const conn = makeConn('c1', 'bob');
|
|
1442
|
+
conn.account = 'bob';
|
|
1443
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, undefined);
|
|
1444
|
+
|
|
1445
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1446
|
+
|
|
1447
|
+
const entry = out.state.members.get('c1');
|
|
1448
|
+
expect(entry?.op).toBe(true);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('does nothing when no services store is bound', () => {
|
|
1452
|
+
const chan = makeChan('#foo');
|
|
1453
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1454
|
+
const conn = makeConn('c1', 'bob');
|
|
1455
|
+
conn.account = 'bob';
|
|
1456
|
+
const ctx = makeCtx(conn);
|
|
1457
|
+
|
|
1458
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1459
|
+
|
|
1460
|
+
const entry = out.state.members.get('c1');
|
|
1461
|
+
expect(entry?.op).toBe(false);
|
|
1462
|
+
expect(entry?.voice).toBe(false);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('does nothing when the channel is not registered', () => {
|
|
1466
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1467
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1468
|
+
const chan = makeChan('#foo');
|
|
1469
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1470
|
+
const conn = makeConn('c1', 'bob');
|
|
1471
|
+
conn.account = 'bob';
|
|
1472
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1473
|
+
|
|
1474
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1475
|
+
|
|
1476
|
+
const entry = out.state.members.get('c1');
|
|
1477
|
+
expect(entry?.op).toBe(false);
|
|
1478
|
+
expect(entry?.voice).toBe(false);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it('auto-op broadcasts use the ChanServ pseudo-client source', () => {
|
|
1482
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
|
|
1483
|
+
services.registerNick('alice', 'pw', 'a@e');
|
|
1484
|
+
services.registerNick('bob', 'pw', 'b@e');
|
|
1485
|
+
services.registerChannel('#foo', 'alice');
|
|
1486
|
+
services.setChannelAccess('#foo', 'bob', 5);
|
|
1487
|
+
const chan = makeChan('#foo');
|
|
1488
|
+
chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
|
|
1489
|
+
const conn = makeConn('c1', 'bob');
|
|
1490
|
+
conn.account = 'bob';
|
|
1491
|
+
const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
|
|
1492
|
+
|
|
1493
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
|
|
1494
|
+
|
|
1495
|
+
const broadcasts = out.effects.flatMap((e) =>
|
|
1496
|
+
e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
|
|
1497
|
+
);
|
|
1498
|
+
expect(
|
|
1499
|
+
broadcasts.some((t) => t.startsWith(':ChanServ!ChanServ@services MODE #foo +o bob')),
|
|
1500
|
+
).toBe(true);
|
|
1501
|
+
});
|
|
1502
|
+
});
|