serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isonReducer } from '../../src/commands/ison';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
|
+
|
|
9
|
+
const baseServerConfig: ServerConfig = {
|
|
10
|
+
serverName: 'irc.example.com',
|
|
11
|
+
networkName: 'ExampleNet',
|
|
12
|
+
maxChannelsPerUser: 30,
|
|
13
|
+
maxTargetsPerCommand: 10,
|
|
14
|
+
maxListEntries: 50,
|
|
15
|
+
nickLen: 30,
|
|
16
|
+
channelLen: 50,
|
|
17
|
+
topicLen: 390,
|
|
18
|
+
quitMessage: 'Client Quit',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeCtx(state: ConnectionState): Ctx {
|
|
22
|
+
return buildCtx({
|
|
23
|
+
serverConfig: baseServerConfig,
|
|
24
|
+
clock: new FakeClock(5_000),
|
|
25
|
+
ids: new SequentialIdFactory(),
|
|
26
|
+
motd: EmptyMotdProvider,
|
|
27
|
+
connection: state,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeState(): ConnectionState {
|
|
32
|
+
const s = createConnection({ id: 'c1', connectedSince: 0 });
|
|
33
|
+
s.nick = 'alice';
|
|
34
|
+
s.user = 'alice';
|
|
35
|
+
s.host = 'example.com';
|
|
36
|
+
s.realname = 'Alice';
|
|
37
|
+
s.registration = 'registered';
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const L = (text: string): RawLine => ({ text });
|
|
42
|
+
|
|
43
|
+
const ison = (...nicks: string[]) => ({ command: 'ISON', params: nicks, tags: {} }) as const;
|
|
44
|
+
|
|
45
|
+
/** Builds an online map from display-case nicks (auto-folds the key). */
|
|
46
|
+
const onlineMap = (...nicks: string[]): Map<string, string> =>
|
|
47
|
+
new Map(nicks.map((n) => [n.toLowerCase(), n]));
|
|
48
|
+
|
|
49
|
+
describe('isonReducer', () => {
|
|
50
|
+
it('emits 303 RPL_ISON with the online nicks from the requested set', () => {
|
|
51
|
+
const state = makeState();
|
|
52
|
+
const ctx = makeCtx(state);
|
|
53
|
+
|
|
54
|
+
const out = isonReducer(onlineMap('bob', 'carol'), ison('bob', 'carol', 'dave'), ctx);
|
|
55
|
+
|
|
56
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
57
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :bob carol')]),
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('only includes nicks that are online', () => {
|
|
62
|
+
const state = makeState();
|
|
63
|
+
const ctx = makeCtx(state);
|
|
64
|
+
|
|
65
|
+
const out = isonReducer(onlineMap('bob'), ison('bob', 'carol', 'dave'), ctx);
|
|
66
|
+
|
|
67
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
68
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('emits an empty trailing when no requested nicks are online', () => {
|
|
73
|
+
const state = makeState();
|
|
74
|
+
const ctx = makeCtx(state);
|
|
75
|
+
|
|
76
|
+
const out = isonReducer(new Map(), ison('nobody'), ctx);
|
|
77
|
+
|
|
78
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
79
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :')]),
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('preserves the registered display spelling from the online map', () => {
|
|
84
|
+
const state = makeState();
|
|
85
|
+
const ctx = makeCtx(state);
|
|
86
|
+
|
|
87
|
+
const out = isonReducer(onlineMap('Bob'), ison('Bob'), ctx);
|
|
88
|
+
|
|
89
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
90
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('matches case-insensitively (rfc1459 case-mapping)', () => {
|
|
95
|
+
const state = makeState();
|
|
96
|
+
const ctx = makeCtx(state);
|
|
97
|
+
|
|
98
|
+
const out = isonReducer(onlineMap('bob'), ison('BOB'), ctx);
|
|
99
|
+
|
|
100
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
101
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('reports the registered spelling even when requested with different case', () => {
|
|
106
|
+
const state = makeState();
|
|
107
|
+
const ctx = makeCtx(state);
|
|
108
|
+
|
|
109
|
+
const out = isonReducer(onlineMap('Bob'), ison('BOB'), ctx);
|
|
110
|
+
|
|
111
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
112
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('uses * as nick placeholder for unregistered connections', () => {
|
|
117
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
118
|
+
const ctx = makeCtx(state);
|
|
119
|
+
|
|
120
|
+
const out = isonReducer(onlineMap('bob'), ison('bob'), ctx);
|
|
121
|
+
|
|
122
|
+
expect(out.effects[0]).toEqual<EffectType>(
|
|
123
|
+
Effect.send('c1', [L(':irc.example.com 303 * :bob')]),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('updates lastSeen to ctx.clock.now()', () => {
|
|
128
|
+
const state = makeState();
|
|
129
|
+
const ctx = makeCtx(state);
|
|
130
|
+
|
|
131
|
+
isonReducer(new Map(), ison('bob'), ctx);
|
|
132
|
+
|
|
133
|
+
expect(state.lastSeen).toBe(5_000);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns the same state reference', () => {
|
|
137
|
+
const state = makeState();
|
|
138
|
+
const ctx = makeCtx(state);
|
|
139
|
+
|
|
140
|
+
const out = isonReducer(new Map(), ison(), ctx);
|
|
141
|
+
|
|
142
|
+
expect(out.state).toBe(state);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('handles no-nick argument (empty request)', () => {
|
|
146
|
+
const state = makeState();
|
|
147
|
+
const ctx = makeCtx(state);
|
|
148
|
+
|
|
149
|
+
const out = isonReducer(onlineMap('bob'), ison(), ctx);
|
|
150
|
+
|
|
151
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
152
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :')]),
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('skips empty-string params in the nick list', () => {
|
|
157
|
+
const state = makeState();
|
|
158
|
+
const ctx = makeCtx(state);
|
|
159
|
+
|
|
160
|
+
const out = isonReducer(onlineMap('bob'), ison('', 'bob'), ctx);
|
|
161
|
+
|
|
162
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
163
|
+
Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { quitReducer } from '../../src/commands/quit';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EmptyMotdProvider,
|
|
7
|
+
FakeClock,
|
|
8
|
+
InMemoryNickHistoryStore,
|
|
9
|
+
SequentialIdFactory,
|
|
10
|
+
} from '../../src/ports';
|
|
6
11
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
12
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
13
|
|
|
@@ -18,13 +23,18 @@ const serverConfig: ServerConfig = {
|
|
|
18
23
|
quitMessage: 'Client Quit',
|
|
19
24
|
};
|
|
20
25
|
|
|
21
|
-
function makeCtx(
|
|
26
|
+
function makeCtx(
|
|
27
|
+
state: ConnectionState,
|
|
28
|
+
clock = new FakeClock(1_000),
|
|
29
|
+
history?: InMemoryNickHistoryStore,
|
|
30
|
+
): Ctx {
|
|
22
31
|
return buildCtx({
|
|
23
32
|
serverConfig,
|
|
24
33
|
clock,
|
|
25
34
|
ids: new SequentialIdFactory(),
|
|
26
35
|
motd: EmptyMotdProvider,
|
|
27
36
|
connection: state,
|
|
37
|
+
...(history !== undefined ? { history } : {}),
|
|
28
38
|
});
|
|
29
39
|
}
|
|
30
40
|
|
|
@@ -217,4 +227,61 @@ describe('quitReducer', () => {
|
|
|
217
227
|
Effect.disconnect('c1'),
|
|
218
228
|
]);
|
|
219
229
|
});
|
|
230
|
+
|
|
231
|
+
it('records a nick-history entry when ctx.history is bound', () => {
|
|
232
|
+
const state = makeRegisteredState();
|
|
233
|
+
const clock = new FakeClock(5_000);
|
|
234
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
235
|
+
const ctx = makeCtx(state, clock, history);
|
|
236
|
+
|
|
237
|
+
quitReducer(state, { command: 'QUIT', params: ['bye'], tags: {} }, ctx);
|
|
238
|
+
|
|
239
|
+
const entries = history.query('alice', 10);
|
|
240
|
+
expect(entries).toHaveLength(1);
|
|
241
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
242
|
+
expect(entries[0]?.connId).toBe('c1');
|
|
243
|
+
expect(entries[0]?.username).toBe('alice');
|
|
244
|
+
expect(entries[0]?.hostname).toBe('example.com');
|
|
245
|
+
expect(entries[0]?.realname).toBe('Alice');
|
|
246
|
+
expect(entries[0]?.signoffTime).toBe(5_000);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('does not record history when no store is bound (ctx.history undefined)', () => {
|
|
250
|
+
const state = makeRegisteredState();
|
|
251
|
+
const ctx = makeCtx(state);
|
|
252
|
+
|
|
253
|
+
const out = quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
|
|
254
|
+
|
|
255
|
+
expect(out.effects).toContainEqual<EffectType>(Effect.disconnect('c1'));
|
|
256
|
+
// No crash; the no-store path is a graceful no-op.
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('does not record history when the connection was never registered', () => {
|
|
260
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
261
|
+
const clock = new FakeClock(5_000);
|
|
262
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
263
|
+
const ctx = makeCtx(state, clock, history);
|
|
264
|
+
|
|
265
|
+
quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
|
|
266
|
+
|
|
267
|
+
expect(history.query('ghost', 10)).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('records a history entry with only nick/connId when user/host/realname are absent', () => {
|
|
271
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
272
|
+
state.nick = 'alice';
|
|
273
|
+
state.registration = 'registered';
|
|
274
|
+
const clock = new FakeClock(5_000);
|
|
275
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
276
|
+
const ctx = makeCtx(state, clock, history);
|
|
277
|
+
|
|
278
|
+
quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
|
|
279
|
+
|
|
280
|
+
const entries = history.query('alice', 10);
|
|
281
|
+
expect(entries).toHaveLength(1);
|
|
282
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
283
|
+
expect(entries[0]?.username).toBeUndefined();
|
|
284
|
+
expect(entries[0]?.hostname).toBeUndefined();
|
|
285
|
+
expect(entries[0]?.realname).toBeUndefined();
|
|
286
|
+
});
|
|
220
287
|
});
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
passReducer,
|
|
10
10
|
userReducer,
|
|
11
11
|
} from '../../src/commands/registration';
|
|
12
|
+
import { DEFAULT_CREATED_TEXT, DEFAULT_SERVER_VERSION } from '../../src/config';
|
|
12
13
|
import { Effect } from '../../src/effects';
|
|
13
14
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
14
15
|
import {
|
|
15
16
|
EmptyMotdProvider,
|
|
16
17
|
FakeClock,
|
|
18
|
+
InMemoryNickHistoryStore,
|
|
17
19
|
SequentialIdFactory,
|
|
18
20
|
StaticMotdProvider,
|
|
19
21
|
} from '../../src/ports';
|
|
@@ -47,6 +49,22 @@ function makeCtx(
|
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/** Like {@link makeCtx} but also threads a bound nick-history store. */
|
|
53
|
+
function makeCtxWithHistory(
|
|
54
|
+
state: ConnectionState,
|
|
55
|
+
history: InMemoryNickHistoryStore,
|
|
56
|
+
clock = new FakeClock(1_000),
|
|
57
|
+
): Ctx {
|
|
58
|
+
return buildCtx({
|
|
59
|
+
serverConfig,
|
|
60
|
+
clock,
|
|
61
|
+
ids: new SequentialIdFactory(),
|
|
62
|
+
motd: EmptyMotdProvider,
|
|
63
|
+
connection: state,
|
|
64
|
+
history,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
function makeState(): ConnectionState {
|
|
51
69
|
return createConnection({ id: 'c1', connectedSince: 0 });
|
|
52
70
|
}
|
|
@@ -86,9 +104,9 @@ function expectedWelcome(nick: string, user: string, host: string | undefined):
|
|
|
86
104
|
);
|
|
87
105
|
return [
|
|
88
106
|
L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
|
|
89
|
-
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version
|
|
90
|
-
L(`${prefix} 003 ${nick} :This server was created in
|
|
91
|
-
L(`${prefix} 004 ${nick} ${sv}
|
|
107
|
+
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
|
|
108
|
+
L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
|
|
109
|
+
L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
|
|
92
110
|
...isupport,
|
|
93
111
|
L(`${prefix} 422 ${nick} :MOTD File is missing`),
|
|
94
112
|
];
|
|
@@ -327,6 +345,63 @@ describe('nickReducer', () => {
|
|
|
327
345
|
expect(out.effects).toEqual([]);
|
|
328
346
|
expect(out.state.nick).toBeUndefined();
|
|
329
347
|
});
|
|
348
|
+
|
|
349
|
+
it('records the old nick into ctx.history on a post-registration nick change', () => {
|
|
350
|
+
const state = registeredState();
|
|
351
|
+
const clock = new FakeClock(5_000);
|
|
352
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
353
|
+
const ctx = makeCtxWithHistory(state, history, clock);
|
|
354
|
+
|
|
355
|
+
nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
|
|
356
|
+
|
|
357
|
+
const entries = history.query('alice', 10);
|
|
358
|
+
expect(entries).toHaveLength(1);
|
|
359
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
360
|
+
expect(entries[0]?.connId).toBe('c1');
|
|
361
|
+
expect(entries[0]?.username).toBe('alice');
|
|
362
|
+
expect(entries[0]?.hostname).toBe('example.com');
|
|
363
|
+
expect(entries[0]?.realname).toBe('Alice');
|
|
364
|
+
expect(entries[0]?.signoffTime).toBe(5_000);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('does not record history on a pre-registration NICK', () => {
|
|
368
|
+
const state = makeState();
|
|
369
|
+
const clock = new FakeClock(5_000);
|
|
370
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
371
|
+
const ctx = makeCtxWithHistory(state, history, clock);
|
|
372
|
+
|
|
373
|
+
nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
|
|
374
|
+
|
|
375
|
+
expect(history.query('alice', 10)).toEqual([]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('does not record history on a nick change when no store is bound', () => {
|
|
379
|
+
const state = registeredState();
|
|
380
|
+
const ctx = makeCtx(state);
|
|
381
|
+
|
|
382
|
+
const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
|
|
383
|
+
|
|
384
|
+
// No crash; the no-store path is a graceful no-op.
|
|
385
|
+
expect(out.state.nick).toBe('bob');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('records a history entry with only nick/connId when user/host/realname are absent', () => {
|
|
389
|
+
const state = makeState();
|
|
390
|
+
state.nick = 'alice';
|
|
391
|
+
state.registration = 'registered';
|
|
392
|
+
const clock = new FakeClock(5_000);
|
|
393
|
+
const history = new InMemoryNickHistoryStore(clock);
|
|
394
|
+
const ctx = makeCtxWithHistory(state, history, clock);
|
|
395
|
+
|
|
396
|
+
nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
|
|
397
|
+
|
|
398
|
+
const entries = history.query('alice', 10);
|
|
399
|
+
expect(entries).toHaveLength(1);
|
|
400
|
+
expect(entries[0]?.nick).toBe('alice');
|
|
401
|
+
expect(entries[0]?.username).toBeUndefined();
|
|
402
|
+
expect(entries[0]?.hostname).toBeUndefined();
|
|
403
|
+
expect(entries[0]?.realname).toBeUndefined();
|
|
404
|
+
});
|
|
330
405
|
});
|
|
331
406
|
|
|
332
407
|
// ============================================================================
|
|
@@ -568,6 +643,76 @@ describe('buildWelcomeLines', () => {
|
|
|
568
643
|
});
|
|
569
644
|
});
|
|
570
645
|
|
|
646
|
+
// ============================================================================
|
|
647
|
+
// buildWelcomeLines — server version / created text driven from ServerConfig
|
|
648
|
+
// ============================================================================
|
|
649
|
+
|
|
650
|
+
describe('buildWelcomeLines — config-driven version & created text', () => {
|
|
651
|
+
/** Builds a ctx whose serverConfig carries the supplied version/created. */
|
|
652
|
+
function makeCtxWithMeta(
|
|
653
|
+
state: ConnectionState,
|
|
654
|
+
serverVersion: string | undefined,
|
|
655
|
+
createdAt: string | number | undefined,
|
|
656
|
+
): Ctx {
|
|
657
|
+
const cfg: ServerConfig = {
|
|
658
|
+
...serverConfig,
|
|
659
|
+
...(serverVersion !== undefined ? { serverVersion } : {}),
|
|
660
|
+
...(createdAt !== undefined ? { createdAt } : {}),
|
|
661
|
+
};
|
|
662
|
+
return buildCtx({
|
|
663
|
+
serverConfig: cfg,
|
|
664
|
+
clock: new FakeClock(1_000),
|
|
665
|
+
ids: new SequentialIdFactory(),
|
|
666
|
+
motd: EmptyMotdProvider,
|
|
667
|
+
connection: state,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function registeredConn(): ConnectionState {
|
|
672
|
+
const s = makeState();
|
|
673
|
+
s.nick = 'alice';
|
|
674
|
+
s.user = 'alice';
|
|
675
|
+
s.host = 'example.com';
|
|
676
|
+
return s;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
it('reflects the configured serverVersion in the 002 and 004 lines', () => {
|
|
680
|
+
const ctx = makeCtxWithMeta(registeredConn(), '9.9.9', undefined);
|
|
681
|
+
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
682
|
+
const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
|
|
683
|
+
const line004 = lines.find((l) => l.text.split(' ')[1] === '004');
|
|
684
|
+
expect(line002?.text).toContain('9.9.9');
|
|
685
|
+
expect(line004?.text).toContain('9.9.9');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('reflects the configured createdAt string in the 003 line', () => {
|
|
689
|
+
const ctx = makeCtxWithMeta(registeredConn(), undefined, '2024-06-01');
|
|
690
|
+
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
691
|
+
const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
|
|
692
|
+
expect(line003?.text).toContain('2024-06-01');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('formats a numeric createdAt (epoch ms) into the 003 line', () => {
|
|
696
|
+
const ctx = makeCtxWithMeta(registeredConn(), undefined, 1_700_000_000_000);
|
|
697
|
+
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
698
|
+
const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
|
|
699
|
+
// 1700000000000ms → 2023-11-14T22:13:20.000Z ; the formatted text must
|
|
700
|
+
// contain a human-readable rendering of that instant, not the raw number.
|
|
701
|
+
expect(line003?.text).not.toContain('1700000000000');
|
|
702
|
+
expect(line003?.text).toContain('2023');
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('falls back to the default version when serverVersion is omitted', () => {
|
|
706
|
+
const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);
|
|
707
|
+
const lines = buildWelcomeLines(ctx.connection, ctx);
|
|
708
|
+
const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
|
|
709
|
+
// The hardcoded placeholder '0.2.0' must no longer appear; the default
|
|
710
|
+
// is the irc-core package version (DEFAULT_SERVER_VERSION).
|
|
711
|
+
expect(line002?.text).not.toContain('0.2.0');
|
|
712
|
+
expect(line002?.text).toContain(DEFAULT_SERVER_VERSION);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
571
716
|
// ============================================================================
|
|
572
717
|
// buildWelcomeLines — MOTD driven from MotdProvider
|
|
573
718
|
// ============================================================================
|
|
@@ -950,9 +1095,9 @@ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
|
|
|
950
1095
|
);
|
|
951
1096
|
return [
|
|
952
1097
|
L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
|
|
953
|
-
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version
|
|
954
|
-
L(`${prefix} 003 ${nick} :This server was created in
|
|
955
|
-
L(`${prefix} 004 ${nick} ${sv}
|
|
1098
|
+
L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
|
|
1099
|
+
L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
|
|
1100
|
+
L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
|
|
956
1101
|
...isupport,
|
|
957
1102
|
L(`${prefix} 422 ${nick} :MOTD File is missing`),
|
|
958
1103
|
];
|
|
@@ -4,7 +4,7 @@ import { capReducer } from '../../src/commands/cap';
|
|
|
4
4
|
import { authenticateReducer } from '../../src/commands/sasl';
|
|
5
5
|
import { Effect } from '../../src/effects';
|
|
6
6
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
-
import type { AccountStore, SaslPayload, SaslResult } from '../../src/ports';
|
|
7
|
+
import type { AccountStore, MtlsIdentityProvider, SaslPayload, SaslResult } from '../../src/ports';
|
|
8
8
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
9
9
|
import { encodeBase64 } from '../../src/protocol/base64';
|
|
10
10
|
import type { IrcMessage } from '../../src/protocol/messages';
|
|
@@ -40,7 +40,11 @@ function saslReadyState(): ConnectionState {
|
|
|
40
40
|
return s;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function makeCtx(
|
|
43
|
+
function makeCtx(
|
|
44
|
+
state: ConnectionState,
|
|
45
|
+
accounts?: AccountStore,
|
|
46
|
+
mtlsIdentity?: MtlsIdentityProvider,
|
|
47
|
+
): Ctx {
|
|
44
48
|
return buildCtx({
|
|
45
49
|
serverConfig,
|
|
46
50
|
clock: new FakeClock(1_000),
|
|
@@ -48,6 +52,7 @@ function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
|
|
|
48
52
|
motd: EmptyMotdProvider,
|
|
49
53
|
connection: state,
|
|
50
54
|
...(accounts !== undefined ? { accounts } : {}),
|
|
55
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
51
56
|
});
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -61,6 +66,19 @@ class FakeAccountStore implements AccountStore {
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
/** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
|
|
70
|
+
class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
|
|
71
|
+
readonly calls: string[] = [];
|
|
72
|
+
constructor(
|
|
73
|
+
private readonly connId: string,
|
|
74
|
+
private readonly identity: string | undefined,
|
|
75
|
+
) {}
|
|
76
|
+
getIdentity(connId: string): string | undefined {
|
|
77
|
+
this.calls.push(connId);
|
|
78
|
+
return connId === this.connId ? this.identity : undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
65
83
|
function plainPayload(authcid: string, password: string, authzid = ''): string {
|
|
66
84
|
return encodeBase64(`${authzid}\0${authcid}\0${password}`);
|
|
@@ -243,25 +261,103 @@ describe('authenticateReducer — no AccountStore configured', () => {
|
|
|
243
261
|
});
|
|
244
262
|
|
|
245
263
|
// ============================================================================
|
|
246
|
-
// AUTHENTICATE EXTERNAL —
|
|
264
|
+
// AUTHENTICATE EXTERNAL — mTLS-backed SASL EXTERNAL
|
|
247
265
|
// ============================================================================
|
|
248
266
|
|
|
249
|
-
describe('authenticateReducer — EXTERNAL
|
|
250
|
-
it('
|
|
267
|
+
describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
268
|
+
it('enters the payload phase when an mTLS provider is configured', () => {
|
|
269
|
+
const state = saslReadyState();
|
|
270
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
271
|
+
const ctx = makeCtx(state, undefined, provider);
|
|
272
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
273
|
+
|
|
274
|
+
expect(state.saslMech).toBe('EXTERNAL');
|
|
275
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('returns 908 ERR_SASLMECHS when no mTLS provider is configured', () => {
|
|
251
279
|
const state = saslReadyState();
|
|
252
280
|
const ctx = makeCtx(state);
|
|
253
281
|
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
254
282
|
|
|
255
283
|
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
256
|
-
L(`:${SV}
|
|
284
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
257
285
|
]);
|
|
286
|
+
expect(state.saslMech).toBeUndefined();
|
|
258
287
|
});
|
|
259
288
|
|
|
260
|
-
it('
|
|
289
|
+
it('succeeds (900/903) when a valid cert identity is presented', () => {
|
|
261
290
|
const state = saslReadyState();
|
|
262
|
-
const
|
|
291
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
292
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
293
|
+
const ctx = makeCtx(state, accounts, provider);
|
|
294
|
+
|
|
263
295
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
296
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
297
|
+
|
|
298
|
+
expect(state.account).toBe('alice');
|
|
299
|
+
expect(accounts.calls).toEqual([
|
|
300
|
+
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
301
|
+
]);
|
|
302
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
303
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
304
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
|
|
309
|
+
const state = saslReadyState();
|
|
310
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
311
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
312
|
+
const ctx = makeCtx(state, accounts, provider);
|
|
313
|
+
|
|
314
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
315
|
+
authenticateReducer(state, authenticate('+'), ctx);
|
|
316
|
+
|
|
264
317
|
expect(state.saslMech).toBeUndefined();
|
|
318
|
+
expect(state.saslBuffer).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('fails with 904 when the provider has no identity for the connection', () => {
|
|
322
|
+
const state = saslReadyState();
|
|
323
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
324
|
+
const provider = new FakeMtlsIdentityProvider('c1', undefined);
|
|
325
|
+
const ctx = makeCtx(state, accounts, provider);
|
|
326
|
+
|
|
327
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
328
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
329
|
+
|
|
330
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
331
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
332
|
+
]);
|
|
333
|
+
expect(state.account).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('fails with 904 when the account store rejects the identity', () => {
|
|
337
|
+
const state = saslReadyState();
|
|
338
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });
|
|
339
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
|
|
340
|
+
const ctx = makeCtx(state, accounts, provider);
|
|
341
|
+
|
|
342
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
343
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
344
|
+
|
|
345
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
346
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
347
|
+
]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('fails with 904 when no account store is configured', () => {
|
|
351
|
+
const state = saslReadyState();
|
|
352
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
353
|
+
const ctx = makeCtx(state, undefined, provider);
|
|
354
|
+
|
|
355
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
356
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
357
|
+
|
|
358
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
359
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
360
|
+
]);
|
|
265
361
|
});
|
|
266
362
|
});
|
|
267
363
|
|
|
@@ -275,6 +371,17 @@ describe('authenticateReducer — unknown mechanism', () => {
|
|
|
275
371
|
const ctx = makeCtx(state);
|
|
276
372
|
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
277
373
|
|
|
374
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
375
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('lists PLAIN,EXTERNAL in 908 when an mTLS provider is configured', () => {
|
|
380
|
+
const state = saslReadyState();
|
|
381
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
382
|
+
const ctx = makeCtx(state, undefined, provider);
|
|
383
|
+
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
384
|
+
|
|
278
385
|
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
279
386
|
L(`:${SV} 908 alice PLAIN,EXTERNAL :are the available SASL mechanisms`),
|
|
280
387
|
]);
|
|
@@ -529,8 +636,9 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
|
|
|
529
636
|
expect(state.account).toBe('alice');
|
|
530
637
|
});
|
|
531
638
|
|
|
532
|
-
it('
|
|
639
|
+
it('advertises the base sasl cap value (PLAIN) without mTLS', () => {
|
|
533
640
|
const advertised = getLsString();
|
|
534
|
-
expect(advertised).toContain('sasl=PLAIN
|
|
641
|
+
expect(advertised).toContain('sasl=PLAIN');
|
|
642
|
+
expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
535
643
|
});
|
|
536
644
|
});
|