serverless-ircd 0.1.0 → 0.2.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getLsString } from '../../src/caps/capabilities';
|
|
3
|
+
import { capReducer } from '../../src/commands/cap';
|
|
4
|
+
import { authenticateReducer } from '../../src/commands/sasl';
|
|
5
|
+
import { Effect } from '../../src/effects';
|
|
6
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
+
import type { AccountStore, SaslPayload, SaslResult } from '../../src/ports';
|
|
8
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
9
|
+
import { encodeBase64 } from '../../src/protocol/base64';
|
|
10
|
+
import type { IrcMessage } from '../../src/protocol/messages';
|
|
11
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
12
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
13
|
+
|
|
14
|
+
const serverConfig: ServerConfig = {
|
|
15
|
+
serverName: 'irc.example.com',
|
|
16
|
+
networkName: 'ExampleNet',
|
|
17
|
+
maxChannelsPerUser: 30,
|
|
18
|
+
maxTargetsPerCommand: 10,
|
|
19
|
+
maxListEntries: 50,
|
|
20
|
+
nickLen: 30,
|
|
21
|
+
channelLen: 50,
|
|
22
|
+
topicLen: 390,
|
|
23
|
+
quitMessage: 'Client Quit',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const L = (text: string): RawLine => ({ text });
|
|
27
|
+
|
|
28
|
+
const SV = 'irc.example.com';
|
|
29
|
+
|
|
30
|
+
/** Standard SASL-over-CAP negotiation state: cap requested, nick set. */
|
|
31
|
+
function saslReadyState(): 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 = 'registering';
|
|
38
|
+
s.capNegotiating = true;
|
|
39
|
+
s.caps.add('sasl');
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
|
|
44
|
+
return buildCtx({
|
|
45
|
+
serverConfig,
|
|
46
|
+
clock: new FakeClock(1_000),
|
|
47
|
+
ids: new SequentialIdFactory(),
|
|
48
|
+
motd: EmptyMotdProvider,
|
|
49
|
+
connection: state,
|
|
50
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A capture-and-respond fake AccountStore. */
|
|
55
|
+
class FakeAccountStore implements AccountStore {
|
|
56
|
+
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
57
|
+
constructor(private readonly result: SaslResult) {}
|
|
58
|
+
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
59
|
+
this.calls.push({ mech, payload });
|
|
60
|
+
return this.result;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
65
|
+
function plainPayload(authcid: string, password: string, authzid = ''): string {
|
|
66
|
+
return encodeBase64(`${authzid}\0${authcid}\0${password}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function authenticate(param: string): IrcMessage {
|
|
70
|
+
return { command: 'AUTHENTICATE', params: [param], tags: {} };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendLinesOf(effect: EffectType | undefined): RawLine[] {
|
|
74
|
+
if (effect === undefined) return [];
|
|
75
|
+
if (effect.tag !== 'Send') return [];
|
|
76
|
+
return effect.lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Returns the text of the first line emitted by `effect`, or `''`. */
|
|
80
|
+
function firstLineText(effect: EffectType | undefined): string {
|
|
81
|
+
return sendLinesOf(effect)[0]?.text ?? '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// AUTHENTICATE PLAIN — happy path
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
describe('authenticateReducer — PLAIN success', () => {
|
|
89
|
+
it('requests the payload with `AUTHENTICATE +` when PLAIN is selected', () => {
|
|
90
|
+
const state = saslReadyState();
|
|
91
|
+
const ctx = makeCtx(state);
|
|
92
|
+
const out = authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
93
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [L('AUTHENTICATE +')])]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('records the selected mechanism on the connection', () => {
|
|
97
|
+
const state = saslReadyState();
|
|
98
|
+
const ctx = makeCtx(state);
|
|
99
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
100
|
+
expect(state.saslMech).toBe('PLAIN');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('completes with 900 and 903 when the AccountStore accepts the credentials', () => {
|
|
104
|
+
const state = saslReadyState();
|
|
105
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
106
|
+
const ctx = makeCtx(state, accounts);
|
|
107
|
+
|
|
108
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
109
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
110
|
+
|
|
111
|
+
const lines = sendLinesOf(out.effects[0]);
|
|
112
|
+
expect(lines).toEqual<RawLine[]>([
|
|
113
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
114
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('records the authenticated account name on the connection', () => {
|
|
119
|
+
const state = saslReadyState();
|
|
120
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
121
|
+
const ctx = makeCtx(state, accounts);
|
|
122
|
+
|
|
123
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
124
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
125
|
+
|
|
126
|
+
expect(state.account).toBe('alice');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('clears the in-progress mechanism after a successful authentication', () => {
|
|
130
|
+
const state = saslReadyState();
|
|
131
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
132
|
+
const ctx = makeCtx(state, accounts);
|
|
133
|
+
|
|
134
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
135
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
136
|
+
|
|
137
|
+
expect(state.saslMech).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('forwards parsed PLAIN credentials to the AccountStore', () => {
|
|
141
|
+
const state = saslReadyState();
|
|
142
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
143
|
+
const ctx = makeCtx(state, accounts);
|
|
144
|
+
|
|
145
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
146
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
147
|
+
|
|
148
|
+
expect(accounts.calls).toEqual([
|
|
149
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('ignores the authorization identity field in the PLAIN payload', () => {
|
|
154
|
+
const state = saslReadyState();
|
|
155
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
156
|
+
const ctx = makeCtx(state, accounts);
|
|
157
|
+
|
|
158
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
159
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret', 'bob')), ctx);
|
|
160
|
+
|
|
161
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
162
|
+
kind: 'PLAIN',
|
|
163
|
+
username: 'alice',
|
|
164
|
+
password: 'secret',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('targets `*` with an empty hostmask when the client has no nick yet', () => {
|
|
169
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
170
|
+
state.capNegotiating = true;
|
|
171
|
+
state.caps.add('sasl');
|
|
172
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
173
|
+
const ctx = makeCtx(state, accounts);
|
|
174
|
+
|
|
175
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
176
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
177
|
+
|
|
178
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
179
|
+
L(`:${SV} 900 * * alice :You are now logged in as alice`),
|
|
180
|
+
L(`:${SV} 903 * :SASL authentication successful`),
|
|
181
|
+
]);
|
|
182
|
+
expect(state.account).toBe('alice');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// AUTHENTICATE PLAIN — wrong password
|
|
188
|
+
// ============================================================================
|
|
189
|
+
|
|
190
|
+
describe('authenticateReducer — PLAIN failure', () => {
|
|
191
|
+
it('emits 904 ERR_SASLFAIL when the AccountStore rejects the credentials', () => {
|
|
192
|
+
const state = saslReadyState();
|
|
193
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
194
|
+
const ctx = makeCtx(state, accounts);
|
|
195
|
+
|
|
196
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
197
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
198
|
+
|
|
199
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
200
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not record an account on failure', () => {
|
|
205
|
+
const state = saslReadyState();
|
|
206
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
207
|
+
const ctx = makeCtx(state, accounts);
|
|
208
|
+
|
|
209
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
210
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
211
|
+
|
|
212
|
+
expect(state.account).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('clears the in-progress mechanism after a failure', () => {
|
|
216
|
+
const state = saslReadyState();
|
|
217
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
218
|
+
const ctx = makeCtx(state, accounts);
|
|
219
|
+
|
|
220
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
221
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
222
|
+
|
|
223
|
+
expect(state.saslMech).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// AUTHENTICATE PLAIN — no AccountStore wired
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
describe('authenticateReducer — no AccountStore configured', () => {
|
|
232
|
+
it('emits 904 when the PLAIN payload arrives but no AccountStore is wired', () => {
|
|
233
|
+
const state = saslReadyState();
|
|
234
|
+
const ctx = makeCtx(state);
|
|
235
|
+
|
|
236
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
237
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
238
|
+
|
|
239
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
240
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// AUTHENTICATE EXTERNAL — stubbed
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
describe('authenticateReducer — EXTERNAL stubbed', () => {
|
|
250
|
+
it('rejects EXTERNAL immediately with 904 (not yet supported)', () => {
|
|
251
|
+
const state = saslReadyState();
|
|
252
|
+
const ctx = makeCtx(state);
|
|
253
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
254
|
+
|
|
255
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
256
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not enter the payload phase for EXTERNAL', () => {
|
|
261
|
+
const state = saslReadyState();
|
|
262
|
+
const ctx = makeCtx(state);
|
|
263
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
264
|
+
expect(state.saslMech).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// AUTHENTICATE <unknown mechanism>
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
describe('authenticateReducer — unknown mechanism', () => {
|
|
273
|
+
it('emits 908 ERR_SASLMECHS listing the available mechanisms', () => {
|
|
274
|
+
const state = saslReadyState();
|
|
275
|
+
const ctx = makeCtx(state);
|
|
276
|
+
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
277
|
+
|
|
278
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
279
|
+
L(`:${SV} 908 alice PLAIN,EXTERNAL :are the available SASL mechanisms`),
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('does not enter the payload phase for an unknown mechanism', () => {
|
|
284
|
+
const state = saslReadyState();
|
|
285
|
+
const ctx = makeCtx(state);
|
|
286
|
+
authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
287
|
+
expect(state.saslMech).toBeUndefined();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// AUTHENTICATE abort path (907)
|
|
293
|
+
// ============================================================================
|
|
294
|
+
|
|
295
|
+
describe('authenticateReducer — abort / already-authenticated', () => {
|
|
296
|
+
it('emits 907 ERR_SASLALREADY when sent after CAP END', () => {
|
|
297
|
+
const state = saslReadyState();
|
|
298
|
+
state.capNegotiating = false;
|
|
299
|
+
const ctx = makeCtx(state);
|
|
300
|
+
const out = authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
301
|
+
|
|
302
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
303
|
+
L(`:${SV} 907 alice :You have already authenticated using SASL`),
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('emits 907 when sent after a successful SASL authentication', () => {
|
|
308
|
+
const state = saslReadyState();
|
|
309
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
310
|
+
const ctx = makeCtx(state, accounts);
|
|
311
|
+
|
|
312
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
313
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
314
|
+
expect(state.account).toBe('alice');
|
|
315
|
+
|
|
316
|
+
const out = authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
317
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
318
|
+
L(`:${SV} 907 alice :You have already authenticated using SASL`),
|
|
319
|
+
]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('emits 907 when no CAP negotiation is in progress', () => {
|
|
323
|
+
const state = saslReadyState();
|
|
324
|
+
state.capNegotiating = false;
|
|
325
|
+
const ctx = makeCtx(state);
|
|
326
|
+
const out = authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
327
|
+
expect(firstLineText(out.effects[0])).toContain(' 907 ');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ============================================================================
|
|
332
|
+
// AUTHENTICATE * — client abort (906)
|
|
333
|
+
// ============================================================================
|
|
334
|
+
|
|
335
|
+
describe('authenticateReducer — client abort', () => {
|
|
336
|
+
it('emits 906 ERR_SASLABORT when the client sends AUTHENTICATE *', () => {
|
|
337
|
+
const state = saslReadyState();
|
|
338
|
+
const ctx = makeCtx(state);
|
|
339
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
340
|
+
const out = authenticateReducer(state, authenticate('*'), ctx);
|
|
341
|
+
|
|
342
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
343
|
+
L(`:${SV} 906 alice :SASL authentication aborted`),
|
|
344
|
+
]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('clears the in-progress mechanism on client abort', () => {
|
|
348
|
+
const state = saslReadyState();
|
|
349
|
+
const ctx = makeCtx(state);
|
|
350
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
351
|
+
authenticateReducer(state, authenticate('*'), ctx);
|
|
352
|
+
expect(state.saslMech).toBeUndefined();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('ignores AUTHENTICATE * when no SASL exchange is in progress', () => {
|
|
356
|
+
const state = saslReadyState();
|
|
357
|
+
const ctx = makeCtx(state);
|
|
358
|
+
const out = authenticateReducer(state, authenticate('*'), ctx);
|
|
359
|
+
expect(out.effects).toEqual([]);
|
|
360
|
+
expect(state.saslMech).toBeUndefined();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ============================================================================
|
|
365
|
+
// AUTHENTICATE + — empty payload marker
|
|
366
|
+
// ============================================================================
|
|
367
|
+
|
|
368
|
+
describe('authenticateReducer — empty payload (`+`)', () => {
|
|
369
|
+
it('treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN', () => {
|
|
370
|
+
const state = saslReadyState();
|
|
371
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'empty' });
|
|
372
|
+
const ctx = makeCtx(state, accounts);
|
|
373
|
+
|
|
374
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
375
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
376
|
+
|
|
377
|
+
expect(accounts.calls).toEqual([]);
|
|
378
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
379
|
+
expect(state.saslMech).toBeUndefined();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ============================================================================
|
|
384
|
+
// AUTHENTICATE — multi-line (chunked) payload
|
|
385
|
+
// ============================================================================
|
|
386
|
+
|
|
387
|
+
describe('authenticateReducer — chunked payload', () => {
|
|
388
|
+
it('buffers chunks of exactly 400 bytes and verifies on the short final chunk', () => {
|
|
389
|
+
const state = saslReadyState();
|
|
390
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
391
|
+
const ctx = makeCtx(state, accounts);
|
|
392
|
+
|
|
393
|
+
const longPassword = 'p'.repeat(320);
|
|
394
|
+
const payload = plainPayload('alice', longPassword);
|
|
395
|
+
expect(payload.length).toBeGreaterThan(400);
|
|
396
|
+
|
|
397
|
+
const first = payload.slice(0, 400);
|
|
398
|
+
const rest = payload.slice(400);
|
|
399
|
+
expect(rest.length).toBeGreaterThan(0);
|
|
400
|
+
expect(rest.length).toBeLessThan(400);
|
|
401
|
+
|
|
402
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
403
|
+
const buffered = authenticateReducer(state, authenticate(first), ctx);
|
|
404
|
+
expect(buffered.effects).toEqual([]);
|
|
405
|
+
expect(state.saslMech).toBe('PLAIN');
|
|
406
|
+
expect(accounts.calls).toEqual([]);
|
|
407
|
+
|
|
408
|
+
authenticateReducer(state, authenticate(rest), ctx);
|
|
409
|
+
expect(accounts.calls).toEqual([
|
|
410
|
+
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: longPassword } },
|
|
411
|
+
]);
|
|
412
|
+
expect(state.account).toBe('alice');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('rejects an oversized payload with 905 ERR_SASLTOOLONG', () => {
|
|
416
|
+
const state = saslReadyState();
|
|
417
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
418
|
+
const ctx = makeCtx(state, accounts);
|
|
419
|
+
|
|
420
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
421
|
+
const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
|
|
422
|
+
|
|
423
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
424
|
+
L(`:${SV} 905 alice :SASL message too long`),
|
|
425
|
+
]);
|
|
426
|
+
expect(state.saslMech).toBeUndefined();
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// AUTHENTICATE — malformed PLAIN payload
|
|
432
|
+
// ============================================================================
|
|
433
|
+
|
|
434
|
+
describe('authenticateReducer — malformed PLAIN', () => {
|
|
435
|
+
it('emits 904 when the base64 payload does not decode to three NUL-separated fields', () => {
|
|
436
|
+
const state = saslReadyState();
|
|
437
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
438
|
+
const ctx = makeCtx(state, accounts);
|
|
439
|
+
|
|
440
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
441
|
+
const out = authenticateReducer(state, authenticate(encodeBase64('onlyonefield')), ctx);
|
|
442
|
+
|
|
443
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
444
|
+
expect(accounts.calls).toEqual([]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('emits 904 when the payload is not valid base64', () => {
|
|
448
|
+
const state = saslReadyState();
|
|
449
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
450
|
+
const ctx = makeCtx(state, accounts);
|
|
451
|
+
|
|
452
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
453
|
+
const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
|
|
454
|
+
|
|
455
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
456
|
+
expect(accounts.calls).toEqual([]);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('emits 908 when the first AUTHENTICATE frame is not a recognized mechanism', () => {
|
|
460
|
+
const state = saslReadyState();
|
|
461
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
462
|
+
const ctx = makeCtx(state, accounts);
|
|
463
|
+
|
|
464
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
465
|
+
|
|
466
|
+
expect(firstLineText(out.effects[0])).toContain(' 908 ');
|
|
467
|
+
expect(accounts.calls).toEqual([]);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ============================================================================
|
|
472
|
+
// AUTHENTICATE — casing & missing parameters
|
|
473
|
+
// ============================================================================
|
|
474
|
+
|
|
475
|
+
describe('authenticateReducer — casing and params', () => {
|
|
476
|
+
it('accepts a lowercase mechanism name', () => {
|
|
477
|
+
const state = saslReadyState();
|
|
478
|
+
const ctx = makeCtx(state);
|
|
479
|
+
const out = authenticateReducer(state, authenticate('plain'), ctx);
|
|
480
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
|
|
481
|
+
expect(state.saslMech).toBe('PLAIN');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('emits 461 when AUTHENTICATE is sent with no parameter', () => {
|
|
485
|
+
const state = saslReadyState();
|
|
486
|
+
const ctx = makeCtx(state);
|
|
487
|
+
const out = authenticateReducer(state, { command: 'AUTHENTICATE', params: [], tags: {} }, ctx);
|
|
488
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
489
|
+
L(`:${SV} 461 alice AUTHENTICATE :Not enough parameters`),
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('targets `*` before the client has a nick', () => {
|
|
494
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
495
|
+
state.capNegotiating = true;
|
|
496
|
+
state.caps.add('sasl');
|
|
497
|
+
const ctx = makeCtx(state);
|
|
498
|
+
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
499
|
+
expect(firstLineText(out.effects[0])).toContain(' 908 * ');
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// CAP REQ sasl → AUTHENTICATE integration (acceptance criterion)
|
|
505
|
+
// ============================================================================
|
|
506
|
+
|
|
507
|
+
describe('authenticateReducer — CAP REQ sasl integration', () => {
|
|
508
|
+
it('completes the full CAP REQ sasl → AUTHENTICATE PLAIN flow', () => {
|
|
509
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
510
|
+
state.nick = 'alice';
|
|
511
|
+
state.user = 'alice';
|
|
512
|
+
state.host = 'example.com';
|
|
513
|
+
state.registration = 'registering';
|
|
514
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
515
|
+
const ctx = makeCtx(state, accounts);
|
|
516
|
+
|
|
517
|
+
capReducer(state, { command: 'CAP', params: ['REQ', 'sasl'], tags: {} }, ctx);
|
|
518
|
+
expect(state.caps.has('sasl')).toBe(true);
|
|
519
|
+
|
|
520
|
+
const mechOut = authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
521
|
+
expect(sendLinesOf(mechOut.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
|
|
522
|
+
|
|
523
|
+
const finalOut = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
524
|
+
const lines = sendLinesOf(finalOut.effects[0]);
|
|
525
|
+
expect(lines.map((l) => l.text)).toEqual([
|
|
526
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
527
|
+
`:${SV} 903 alice :SASL authentication successful`,
|
|
528
|
+
]);
|
|
529
|
+
expect(state.account).toBe('alice');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('does not affect the advertised sasl cap value (PLAIN,EXTERNAL)', () => {
|
|
533
|
+
const advertised = getLsString();
|
|
534
|
+
expect(advertised).toContain('sasl=PLAIN,EXTERNAL');
|
|
535
|
+
});
|
|
536
|
+
});
|