serverless-ircd 0.2.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 +258 -18
- package/README.md +72 -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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- 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 +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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 +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- 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 +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- 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/cf-runtime.test.ts +91 -0
- 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/connection-do.test.ts +40 -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 +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- 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/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -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 +84 -3
- 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 +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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 +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -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 +182 -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 +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -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 +78 -13
- 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 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type AccountStore,
|
|
4
|
+
InMemoryAccountStore,
|
|
5
|
+
type SaslPayload,
|
|
6
|
+
type SaslResult,
|
|
7
|
+
} from '../src/ports';
|
|
8
|
+
|
|
9
|
+
/** Convenience: a PLAIN payload. */
|
|
10
|
+
function plain(username: string, password: string): SaslPayload {
|
|
11
|
+
return { kind: 'PLAIN', username, password };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('InMemoryAccountStore — PLAIN', () => {
|
|
15
|
+
it('implements the AccountStore port', () => {
|
|
16
|
+
const store: AccountStore = new InMemoryAccountStore([
|
|
17
|
+
{ username: 'alice', password: 'secret' },
|
|
18
|
+
]);
|
|
19
|
+
expect(store).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
23
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
24
|
+
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
25
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a wrong password', () => {
|
|
29
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
30
|
+
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
31
|
+
expect(out.ok).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects an unknown username', () => {
|
|
35
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
36
|
+
const out = store.verify('PLAIN', plain('bob', 'secret'));
|
|
37
|
+
expect(out.ok).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('matches the first of several configured accounts', () => {
|
|
41
|
+
const store = new InMemoryAccountStore([
|
|
42
|
+
{ username: 'alice', password: 'a-secret' },
|
|
43
|
+
{ username: 'bob', password: 'b-secret' },
|
|
44
|
+
]);
|
|
45
|
+
expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual<SaslResult>({
|
|
46
|
+
ok: true,
|
|
47
|
+
account: 'bob',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects when constructed with an empty account list', () => {
|
|
52
|
+
const store = new InMemoryAccountStore([]);
|
|
53
|
+
const out = store.verify('PLAIN', plain('alice', 'secret'));
|
|
54
|
+
expect(out.ok).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not confuse credentials across accounts (no password cross-match)', () => {
|
|
58
|
+
const store = new InMemoryAccountStore([
|
|
59
|
+
{ username: 'alice', password: 'a-secret' },
|
|
60
|
+
{ username: 'bob', password: 'b-secret' },
|
|
61
|
+
]);
|
|
62
|
+
// bob's password with alice's username must fail.
|
|
63
|
+
expect(store.verify('PLAIN', plain('alice', 'b-secret')).ok).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('InMemoryAccountStore — mechanism handling', () => {
|
|
68
|
+
it('rejects an unsupported mechanism with a failure result', () => {
|
|
69
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
70
|
+
const out = store.verify('CRAM-MD5', { kind: 'RAW', data: 'whatever' });
|
|
71
|
+
expect(out.ok).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats the mechanism name case-insensitively for PLAIN', () => {
|
|
75
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
76
|
+
const out = store.verify('plain', plain('alice', 'secret'));
|
|
77
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('InMemoryAccountStore — EXTERNAL (mTLS)', () => {
|
|
82
|
+
it('maps a known cert subject to its account', () => {
|
|
83
|
+
const store = new InMemoryAccountStore([
|
|
84
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
85
|
+
]);
|
|
86
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
87
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects an unknown cert subject', () => {
|
|
91
|
+
const store = new InMemoryAccountStore([
|
|
92
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
93
|
+
]);
|
|
94
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=evil' });
|
|
95
|
+
expect(out.ok).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects EXTERNAL when no cert subjects are configured', () => {
|
|
99
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
100
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
101
|
+
expect(out.ok).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('treats the mechanism name case-insensitively for EXTERNAL', () => {
|
|
105
|
+
const store = new InMemoryAccountStore([
|
|
106
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
107
|
+
]);
|
|
108
|
+
const out = store.verify('external', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
109
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('maps multiple accounts with distinct cert subjects', () => {
|
|
113
|
+
const store = new InMemoryAccountStore([
|
|
114
|
+
{ username: 'alice', password: 'a', certSubject: 'CN=alice' },
|
|
115
|
+
{ username: 'bob', password: 'b', certSubject: 'CN=bob' },
|
|
116
|
+
]);
|
|
117
|
+
expect(store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=bob' })).toEqual<SaslResult>({
|
|
118
|
+
ok: true,
|
|
119
|
+
account: 'bob',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('InMemoryAccountStore — failure reason', () => {
|
|
125
|
+
it('returns a human-readable reason on failure', () => {
|
|
126
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
127
|
+
const out = store.verify('PLAIN', plain('alice', 'wrong'));
|
|
128
|
+
if (out.ok) throw new Error('expected failure');
|
|
129
|
+
expect(out.reason.length).toBeGreaterThan(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -31,10 +31,10 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
31
31
|
);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it('declares the sasl value as the
|
|
34
|
+
it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
|
|
35
35
|
const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
|
|
36
36
|
expect(sasl).toBeDefined();
|
|
37
|
-
expect(sasl?.value).toBe('PLAIN
|
|
37
|
+
expect(sasl?.value).toBe('PLAIN');
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it('does not attach a value to caps that have no parameter', () => {
|
|
@@ -107,7 +107,8 @@ describe('getLsString', () => {
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
it('contains the sasl token with its value', () => {
|
|
110
|
-
expect(getLsString()).toContain('sasl=PLAIN
|
|
110
|
+
expect(getLsString()).toContain('sasl=PLAIN');
|
|
111
|
+
expect(getLsString()).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
it('contains server-time as a bare name', () => {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fc from 'fast-check';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type CaseMapping, caseFold } from '../src/case-fold';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// caseFold — rfc1459 special-character folding
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
describe('caseFold — rfc1459', () => {
|
|
10
|
+
it('folds [Foo] to {foo}', () => {
|
|
11
|
+
expect(caseFold('rfc1459', '[Foo]')).toBe('{foo}');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('folds uppercase A–Z to a–z', () => {
|
|
15
|
+
expect(caseFold('rfc1459', 'ABCXYZ')).toBe('abcxyz');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('folds the four rfc1459 specials [ \\ ] ^ to { | } ~', () => {
|
|
19
|
+
expect(caseFold('rfc1459', '[\\]^')).toBe('{|}~');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not fold { | } ~ any further', () => {
|
|
23
|
+
expect(caseFold('rfc1459', '{|}~')).toBe('{|}~');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('leaves non-folding characters unchanged', () => {
|
|
27
|
+
expect(caseFold('rfc1459', 'a1_-.#@')).toBe('a1_-.#@');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns the empty string unchanged', () => {
|
|
31
|
+
expect(caseFold('rfc1459', '')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// caseFold — ascii mapping (strict subset of rfc1459)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
describe('caseFold — ascii', () => {
|
|
40
|
+
it('folds uppercase A–Z to a–z', () => {
|
|
41
|
+
expect(caseFold('ascii', 'ABCXYZ')).toBe('abcxyz');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('does NOT fold the rfc1459 specials [ \\ ] ^', () => {
|
|
45
|
+
expect(caseFold('ascii', '[Foo]')).toBe('[foo]');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves non-folding characters unchanged', () => {
|
|
49
|
+
expect(caseFold('ascii', 'a1_-.#[]')).toBe('a1_-.#[]');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// caseFold — idempotency (property test)
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
describe('caseFold — idempotency', () => {
|
|
58
|
+
const mappings: CaseMapping[] = ['rfc1459', 'ascii'];
|
|
59
|
+
|
|
60
|
+
for (const mapping of mappings) {
|
|
61
|
+
it(`fold(fold(s)) === fold(s) under ${mapping}`, () => {
|
|
62
|
+
const nickish = fc.stringMatching(/[\x21-\x7e]{0,16}/u);
|
|
63
|
+
fc.assert(
|
|
64
|
+
fc.property(nickish, (s) => {
|
|
65
|
+
const once = caseFold(mapping, s);
|
|
66
|
+
expect(caseFold(mapping, once)).toBe(once);
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it('rfc1459 and ascii agree on pure A–Z / a–z input', () => {
|
|
73
|
+
const alpha = fc.stringMatching(/^[A-Za-z]{1,16}$/u);
|
|
74
|
+
fc.assert(
|
|
75
|
+
fc.property(alpha, (s) => {
|
|
76
|
+
expect(caseFold('rfc1459', s)).toBe(caseFold('ascii', s));
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -4,6 +4,7 @@ import { capReducer } from '../../src/commands/cap';
|
|
|
4
4
|
import { userReducer } from '../../src/commands/registration';
|
|
5
5
|
import { Effect } from '../../src/effects';
|
|
6
6
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
+
import type { MtlsIdentityProvider } from '../../src/ports';
|
|
7
8
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
8
9
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
9
10
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -20,13 +21,18 @@ const serverConfig: ServerConfig = {
|
|
|
20
21
|
quitMessage: 'Client Quit',
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
function makeCtx(
|
|
24
|
+
function makeCtx(
|
|
25
|
+
state: ConnectionState,
|
|
26
|
+
cfg: ServerConfig = serverConfig,
|
|
27
|
+
mtlsIdentity?: MtlsIdentityProvider,
|
|
28
|
+
): Ctx {
|
|
24
29
|
return buildCtx({
|
|
25
30
|
serverConfig: cfg,
|
|
26
31
|
clock: new FakeClock(1_000),
|
|
27
32
|
ids: new SequentialIdFactory(),
|
|
28
33
|
motd: EmptyMotdProvider,
|
|
29
34
|
connection: state,
|
|
35
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
30
36
|
});
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -110,6 +116,32 @@ describe('capReducer — LS', () => {
|
|
|
110
116
|
}
|
|
111
117
|
});
|
|
112
118
|
|
|
119
|
+
it('advertises sasl=PLAIN when no mTLS provider is configured', () => {
|
|
120
|
+
const state = makeState();
|
|
121
|
+
const ctx = makeCtx(state);
|
|
122
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
123
|
+
const send = out.effects[0];
|
|
124
|
+
expect(send).toBeDefined();
|
|
125
|
+
if (send?.tag === 'Send') {
|
|
126
|
+
const body = send.lines[0]?.text ?? '';
|
|
127
|
+
expect(body).toContain('sasl=PLAIN');
|
|
128
|
+
expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
|
|
133
|
+
const state = makeState();
|
|
134
|
+
const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
|
|
135
|
+
const ctx = makeCtx(state, serverConfig, provider);
|
|
136
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
137
|
+
const send = out.effects[0];
|
|
138
|
+
expect(send).toBeDefined();
|
|
139
|
+
if (send?.tag === 'Send') {
|
|
140
|
+
const body = send.lines[0]?.text ?? '';
|
|
141
|
+
expect(body).toContain('sasl=PLAIN,EXTERNAL');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
113
145
|
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
114
146
|
const state = makeState();
|
|
115
147
|
const ctx = makeCtx(state);
|
|
@@ -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
|
+
});
|
|
@@ -134,6 +134,21 @@ describe('kickReducer — success', () => {
|
|
|
134
134
|
);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
it('resolves the target using rfc1459 case-mapping ([ and { are equal)', () => {
|
|
138
|
+
const chan = makeChan('#foo');
|
|
139
|
+
addMember(chan, 'c1', 'alice', true);
|
|
140
|
+
addMember(chan, 'c2', 'foo[bar]');
|
|
141
|
+
const conn = makeConn('c1', 'alice');
|
|
142
|
+
const ctx = makeCtx(conn);
|
|
143
|
+
|
|
144
|
+
const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'FOO{BAR}'], tags: {} }, ctx);
|
|
145
|
+
|
|
146
|
+
expect(out.state.members.has('c2')).toBe(false);
|
|
147
|
+
expect(out.effects).toContainEqual<EffectType>(
|
|
148
|
+
Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
137
152
|
it('updates the kicker lastSeen to ctx.clock.now()', () => {
|
|
138
153
|
const chan = makeChan('#foo');
|
|
139
154
|
addMember(chan, 'c1', 'alice', true);
|