serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CHANSERV_NICK } from '../../src/commands/chanserv';
|
|
3
|
+
import { HOSTSERV_NICK } from '../../src/commands/hostserv';
|
|
4
|
+
import { MEMOSERV_NICK } from '../../src/commands/memoserv';
|
|
5
|
+
import { NICKSERV_NICK } from '../../src/commands/nickserv';
|
|
6
|
+
import { OPERSERV_NICK } from '../../src/commands/operserv';
|
|
7
|
+
import { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from '../../src/commands/service-aliases';
|
|
8
|
+
|
|
9
|
+
describe('service-aliases — single source of truth for shortcut verbs', () => {
|
|
10
|
+
it('maps every Atheme/Anope shortcut verb to its canonical service nick', () => {
|
|
11
|
+
const expected: ReadonlyArray<[string, string]> = [
|
|
12
|
+
['NICKSERV', NICKSERV_NICK],
|
|
13
|
+
['NS', NICKSERV_NICK],
|
|
14
|
+
['CHANSERV', CHANSERV_NICK],
|
|
15
|
+
['CS', CHANSERV_NICK],
|
|
16
|
+
['HOSTSERV', HOSTSERV_NICK],
|
|
17
|
+
['HS', HOSTSERV_NICK],
|
|
18
|
+
['MEMOSERV', MEMOSERV_NICK],
|
|
19
|
+
['MS', MEMOSERV_NICK],
|
|
20
|
+
['OPERSERV', OPERSERV_NICK],
|
|
21
|
+
['OS', OPERSERV_NICK],
|
|
22
|
+
];
|
|
23
|
+
expect(SHORTCUT_TO_SERVICE.size).toBe(10);
|
|
24
|
+
for (const [alias, nick] of expected) {
|
|
25
|
+
expect(SHORTCUT_TO_SERVICE.get(alias)).toBe(nick);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keys every alias uppercased so the dispatcher can do a case-insensitive lookup', () => {
|
|
30
|
+
for (const alias of SHORTCUT_TO_SERVICE.keys()) {
|
|
31
|
+
expect(alias).toBe(alias.toUpperCase());
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('SERVICE_NICKS is the de-duplicated set of service nicks from the map values', () => {
|
|
36
|
+
const expected = Array.from(new Set(SHORTCUT_TO_SERVICE.values())).sort();
|
|
37
|
+
expect([...SERVICE_NICKS].sort()).toEqual(expected);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('every value in SHORTCUT_TO_SERVICE is one of the canonical *_NICK constants', () => {
|
|
41
|
+
const canonical = new Set<string>([
|
|
42
|
+
NICKSERV_NICK,
|
|
43
|
+
CHANSERV_NICK,
|
|
44
|
+
HOSTSERV_NICK,
|
|
45
|
+
MEMOSERV_NICK,
|
|
46
|
+
OPERSERV_NICK,
|
|
47
|
+
]);
|
|
48
|
+
for (const nick of SHORTCUT_TO_SERVICE.values()) {
|
|
49
|
+
expect(canonical.has(nick)).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { describe, expect, it } from 'vitest';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
applyAccountSuccess,
|
|
20
|
+
attemptPassAccountAuth,
|
|
21
|
+
passBasedAccountAuth,
|
|
22
|
+
} from '../../src/commands/account-auth';
|
|
19
23
|
import { NICKSERV_NICK, nickservReducer } from '../../src/commands/nickserv';
|
|
24
|
+
import { passReducer } from '../../src/commands/registration';
|
|
20
25
|
import { authenticateReducer } from '../../src/commands/sasl';
|
|
21
|
-
import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
|
|
22
26
|
import type { Effect as EffectType } from '../../src/effects';
|
|
23
|
-
import type { AccountStore } from '../../src/ports';
|
|
24
27
|
import {
|
|
25
28
|
EmptyMotdProvider,
|
|
26
29
|
FakeClock,
|
|
@@ -71,18 +74,13 @@ function saslReadyState(nick = 'alice'): ConnectionState {
|
|
|
71
74
|
return s;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function makeCtx(
|
|
75
|
-
state: ConnectionState,
|
|
76
|
-
accounts?: AccountStore,
|
|
77
|
-
services?: InMemoryServicesStore,
|
|
78
|
-
): Ctx {
|
|
77
|
+
function makeCtx(state: ConnectionState, services?: InMemoryServicesStore): Ctx {
|
|
79
78
|
return buildCtx({
|
|
80
79
|
serverConfig,
|
|
81
80
|
clock: new FakeClock(NOW),
|
|
82
81
|
ids: new SequentialIdFactory(),
|
|
83
82
|
motd: EmptyMotdProvider,
|
|
84
83
|
connection: state,
|
|
85
|
-
...(accounts !== undefined ? { accounts } : {}),
|
|
86
84
|
...(services !== undefined ? { services } : {}),
|
|
87
85
|
});
|
|
88
86
|
}
|
|
@@ -119,6 +117,80 @@ function emptySnapshot(): ServicesSnapshot {
|
|
|
119
117
|
};
|
|
120
118
|
}
|
|
121
119
|
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// AC: NickServ REGISTER → PASS <nick>:<password> succeeds post-registration
|
|
122
|
+
// (T178: PASS-auth consults ServicesStore.verifyNick — the bug fix)
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
describe('T178 unified account: NickServ REGISTER → PASS-auth (post-registration)', () => {
|
|
126
|
+
it('a freshly NickServ-registered nick authenticates via PASS <nick>:<password>', () => {
|
|
127
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
128
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
129
|
+
nickservReducer(
|
|
130
|
+
registrar,
|
|
131
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
132
|
+
makeCtx(registrar, services),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// A second connection sending PASS after registration (no AccountStore).
|
|
136
|
+
const conn = makeConn('c1', 'alice');
|
|
137
|
+
const ctx = makeCtx(conn, services);
|
|
138
|
+
const out = passReducer(conn, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
139
|
+
|
|
140
|
+
expect(conn.account).toBe('alice');
|
|
141
|
+
expect(conn.userModes.registered).toBe(true);
|
|
142
|
+
const texts = sentTexts(out.effects);
|
|
143
|
+
expect(texts).toContain(
|
|
144
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
145
|
+
);
|
|
146
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('PASS-auth with the wrong password after NickServ REGISTER is a silent no-op', () => {
|
|
150
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
151
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
152
|
+
nickservReducer(
|
|
153
|
+
registrar,
|
|
154
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
155
|
+
makeCtx(registrar, services),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const conn = makeConn('c1', 'alice');
|
|
159
|
+
const ctx = makeCtx(conn, services);
|
|
160
|
+
const out = passReducer(conn, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
161
|
+
|
|
162
|
+
expect(conn.account).toBeUndefined();
|
|
163
|
+
expect(out.effects).toEqual<EffectType[]>([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('passBasedAccountAuth identifies via services.verifyNick at registration completion', () => {
|
|
167
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
168
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
169
|
+
nickservReducer(
|
|
170
|
+
registrar,
|
|
171
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
172
|
+
makeCtx(registrar, services),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const conn = makeConn('c1', 'alice');
|
|
176
|
+
conn.registration = 'registering';
|
|
177
|
+
conn.passAttempt = 'alice:hunter2';
|
|
178
|
+
const effects = passBasedAccountAuth(conn, makeCtx(conn, services));
|
|
179
|
+
|
|
180
|
+
expect(conn.account).toBe('alice');
|
|
181
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('attemptPassAccountAuth consults services.verifyNick directly', () => {
|
|
185
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
186
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
187
|
+
const conn = makeConn('c1', 'alice');
|
|
188
|
+
const effects = attemptPassAccountAuth(conn, makeCtx(conn, services), 'alice:hunter2');
|
|
189
|
+
expect(conn.account).toBe('alice');
|
|
190
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
122
194
|
// ============================================================================
|
|
123
195
|
// AC: NickServ REGISTER → SASL PLAIN succeeds with the same password
|
|
124
196
|
// ============================================================================
|
|
@@ -127,12 +199,12 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
127
199
|
it('a freshly NickServ-registered nick authenticates via SASL PLAIN with no second registration', () => {
|
|
128
200
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
129
201
|
const registrar = makeConn('c-reg', 'alice');
|
|
130
|
-
const regCtx = makeCtx(registrar,
|
|
202
|
+
const regCtx = makeCtx(registrar, services);
|
|
131
203
|
nickservReducer(registrar, privmsg('REGISTER hunter2 alice@example.com'), regCtx);
|
|
132
204
|
|
|
133
205
|
// A second connection speaking SASL PLAIN with the same nick:password.
|
|
134
206
|
const saslConn = saslReadyState('alice');
|
|
135
|
-
const saslCtx = makeCtx(saslConn,
|
|
207
|
+
const saslCtx = makeCtx(saslConn, services);
|
|
136
208
|
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
137
209
|
const out = authenticateReducer(
|
|
138
210
|
saslConn,
|
|
@@ -155,11 +227,11 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
155
227
|
nickservReducer(
|
|
156
228
|
registrar,
|
|
157
229
|
privmsg('REGISTER hunter2 alice@example.com'),
|
|
158
|
-
makeCtx(registrar,
|
|
230
|
+
makeCtx(registrar, services),
|
|
159
231
|
);
|
|
160
232
|
|
|
161
233
|
const saslConn = saslReadyState('alice');
|
|
162
|
-
const saslCtx = makeCtx(saslConn,
|
|
234
|
+
const saslCtx = makeCtx(saslConn, services);
|
|
163
235
|
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
164
236
|
const out = authenticateReducer(
|
|
165
237
|
saslConn,
|
|
@@ -179,10 +251,10 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
179
251
|
describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', () => {
|
|
180
252
|
it('NickServ INFO resolves an account seeded into the unified store', () => {
|
|
181
253
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
182
|
-
services.
|
|
254
|
+
services.registerNick('alice', 'hunter2', '');
|
|
183
255
|
|
|
184
256
|
const conn = makeConn('c1', 'bob');
|
|
185
|
-
const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn,
|
|
257
|
+
const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, services));
|
|
186
258
|
|
|
187
259
|
const texts = sentTexts(out.effects);
|
|
188
260
|
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: alice');
|
|
@@ -191,22 +263,18 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
191
263
|
|
|
192
264
|
it('NickServ isRegisteredNick resolves a seeded SASL account', () => {
|
|
193
265
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
194
|
-
services.
|
|
266
|
+
services.registerNick('alice', 'hunter2', '');
|
|
195
267
|
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
196
268
|
expect(services.isRegisteredNick('ALICE')).toBe(true);
|
|
197
269
|
});
|
|
198
270
|
|
|
199
271
|
it('NickServ SET ENFORCE applies to a seeded SASL account once identified', () => {
|
|
200
272
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
201
|
-
services.
|
|
273
|
+
services.registerNick('alice', 'hunter2', '');
|
|
202
274
|
const conn = makeConn('c1', 'alice');
|
|
203
275
|
conn.account = 'alice';
|
|
204
276
|
conn.userModes.registered = true;
|
|
205
|
-
const out = nickservReducer(
|
|
206
|
-
conn,
|
|
207
|
-
privmsg('SET ENFORCE kill'),
|
|
208
|
-
makeCtx(conn, undefined, services),
|
|
209
|
-
);
|
|
277
|
+
const out = nickservReducer(conn, privmsg('SET ENFORCE kill'), makeCtx(conn, services));
|
|
210
278
|
|
|
211
279
|
expect(services.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
|
|
212
280
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -216,13 +284,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
216
284
|
|
|
217
285
|
it('NickServ IDENTIFY succeeds against a seeded SASL account', () => {
|
|
218
286
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
219
|
-
services.
|
|
287
|
+
services.registerNick('alice', 'hunter2', '');
|
|
220
288
|
const conn = makeConn('c1', 'alice');
|
|
221
|
-
const out = nickservReducer(
|
|
222
|
-
conn,
|
|
223
|
-
privmsg('IDENTIFY hunter2'),
|
|
224
|
-
makeCtx(conn, undefined, services),
|
|
225
|
-
);
|
|
289
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
|
|
226
290
|
|
|
227
291
|
expect(conn.account).toBe('alice');
|
|
228
292
|
expect(conn.userModes.registered).toBe(true);
|
|
@@ -233,9 +297,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
233
297
|
|
|
234
298
|
it('NickServ IDENTIFY with the wrong password fails against a seeded SASL account', () => {
|
|
235
299
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
236
|
-
services.
|
|
300
|
+
services.registerNick('alice', 'hunter2', '');
|
|
237
301
|
const conn = makeConn('c1', 'alice');
|
|
238
|
-
const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn,
|
|
302
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, services));
|
|
239
303
|
|
|
240
304
|
expect(conn.account).toBeUndefined();
|
|
241
305
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -243,14 +307,14 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
243
307
|
);
|
|
244
308
|
});
|
|
245
309
|
|
|
246
|
-
it('a NickServ REGISTER does not overwrite an
|
|
310
|
+
it('a NickServ REGISTER does not overwrite an existing credential', () => {
|
|
247
311
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
248
|
-
services.
|
|
312
|
+
services.registerNick('alice', 'hunter2', '');
|
|
249
313
|
const conn = makeConn('c1', 'alice');
|
|
250
314
|
const out = nickservReducer(
|
|
251
315
|
conn,
|
|
252
316
|
privmsg('REGISTER otherpw alice@example.com'),
|
|
253
|
-
makeCtx(conn,
|
|
317
|
+
makeCtx(conn, services),
|
|
254
318
|
);
|
|
255
319
|
|
|
256
320
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -260,14 +324,6 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
260
324
|
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
261
325
|
expect(services.verifyNick('alice', 'otherpw').ok).toBe(false);
|
|
262
326
|
});
|
|
263
|
-
|
|
264
|
-
it('ingestAccountCredential returns false when the nick is already registered', () => {
|
|
265
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
266
|
-
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
267
|
-
expect(services.ingestAccountCredential(hashAccountCredential('alice', 'otherpw'))).toBe(false);
|
|
268
|
-
// The original credential still verifies.
|
|
269
|
-
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
270
|
-
});
|
|
271
327
|
});
|
|
272
328
|
|
|
273
329
|
// ============================================================================
|
|
@@ -277,15 +333,11 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
277
333
|
describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
|
|
278
334
|
it('IDENTIFY delivers queued memos (parity with SASL success)', () => {
|
|
279
335
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
280
|
-
services.
|
|
336
|
+
services.registerNick('alice', 'hunter2', '');
|
|
281
337
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
|
|
282
338
|
|
|
283
339
|
const conn = makeConn('c1', 'alice');
|
|
284
|
-
const out = nickservReducer(
|
|
285
|
-
conn,
|
|
286
|
-
privmsg('IDENTIFY hunter2'),
|
|
287
|
-
makeCtx(conn, undefined, services),
|
|
288
|
-
);
|
|
340
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
|
|
289
341
|
|
|
290
342
|
expect(sentTexts(out.effects)).toContain(
|
|
291
343
|
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
|
|
@@ -294,9 +346,9 @@ describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
|
|
|
294
346
|
|
|
295
347
|
it('both paths share applyAccountSuccess for +r stamping', () => {
|
|
296
348
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
297
|
-
services.
|
|
349
|
+
services.registerNick('alice', 'hunter2', '');
|
|
298
350
|
const conn = makeConn('c1', 'alice');
|
|
299
|
-
const ctx = makeCtx(conn,
|
|
351
|
+
const ctx = makeCtx(conn, services);
|
|
300
352
|
|
|
301
353
|
const effects = applyAccountSuccess(conn, ctx, 'alice');
|
|
302
354
|
|
|
@@ -356,6 +408,7 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
|
|
|
356
408
|
email: 'l@x',
|
|
357
409
|
createdAt: NOW,
|
|
358
410
|
enforce: 'none',
|
|
411
|
+
certSubjects: [],
|
|
359
412
|
// Malformed credential — empty hash, rejected by verifyHashedPassword.
|
|
360
413
|
credential: { account: 'legacy', algorithm: 'scrypt', salt: 's', hash: '' },
|
|
361
414
|
},
|
|
@@ -379,38 +432,3 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
|
|
|
379
432
|
expect(JSON.stringify(snap.nicks[0])).not.toContain('hunter2');
|
|
380
433
|
});
|
|
381
434
|
});
|
|
382
|
-
|
|
383
|
-
// ============================================================================
|
|
384
|
-
// Adapter ingestion contract: ingestAccountCredential accepts HashedAccountCredential
|
|
385
|
-
// ============================================================================
|
|
386
|
-
|
|
387
|
-
describe('T156 unified account: ingestAccountCredential', () => {
|
|
388
|
-
it('creates a registered nick with default email / enforce from a hashed credential', () => {
|
|
389
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
390
|
-
const cred = hashAccountCredential('alice', 'hunter2');
|
|
391
|
-
expect(services.ingestAccountCredential(cred)).toBe(true);
|
|
392
|
-
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
393
|
-
const rec = services.getNick('alice');
|
|
394
|
-
expect(rec).toBeDefined();
|
|
395
|
-
if (rec !== undefined) {
|
|
396
|
-
expect(rec.account).toBe('alice');
|
|
397
|
-
expect(rec.enforce).toBe<NickEnforcePolicy>('none');
|
|
398
|
-
}
|
|
399
|
-
// verifyNick goes through the same scrypt path as registerNick.
|
|
400
|
-
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('preserves the original spelling of the account name', () => {
|
|
404
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
405
|
-
const cred: HashedAccountCredential = {
|
|
406
|
-
account: 'Alice',
|
|
407
|
-
algorithm: 'scrypt',
|
|
408
|
-
salt: 's',
|
|
409
|
-
hash: 'aGVsbG8=',
|
|
410
|
-
};
|
|
411
|
-
services.ingestAccountCredential(cred);
|
|
412
|
-
const rec = services.getNick('Alice');
|
|
413
|
-
expect(rec?.nick).toBe('Alice');
|
|
414
|
-
expect(rec?.account).toBe('Alice');
|
|
415
|
-
});
|
|
416
|
-
});
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
type HashedAccountCredential,
|
|
4
|
-
HashedAccountStore,
|
|
5
4
|
hashAccountCredential,
|
|
6
5
|
verifyHashedPassword,
|
|
7
6
|
} from '../src/credential-hashing';
|
|
8
|
-
import type { AccountStore, SaslPayload } from '../src/ports';
|
|
9
7
|
|
|
10
8
|
describe('hashAccountCredential', () => {
|
|
11
9
|
it('produces an entry keyed by the username with scrypt', () => {
|
|
@@ -92,79 +90,3 @@ describe('verifyHashedPassword', () => {
|
|
|
92
90
|
expect(verifyHashedPassword('s3cret', entry)).toBe(false);
|
|
93
91
|
});
|
|
94
92
|
});
|
|
95
|
-
|
|
96
|
-
describe('HashedAccountStore', () => {
|
|
97
|
-
function plain(username: string, password: string): SaslPayload {
|
|
98
|
-
return { kind: 'PLAIN', username, password };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
it('implements the AccountStore port', () => {
|
|
102
|
-
const store: AccountStore = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
103
|
-
expect(store).toBeDefined();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
107
|
-
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
108
|
-
expect(store.verify('PLAIN', plain('alice', 's3cret'))).toEqual({
|
|
109
|
-
ok: true,
|
|
110
|
-
account: 'alice',
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('rejects a wrong password', () => {
|
|
115
|
-
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
116
|
-
expect(store.verify('PLAIN', plain('alice', 'wrong')).ok).toBe(false);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('rejects an unknown username', () => {
|
|
120
|
-
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
121
|
-
expect(store.verify('PLAIN', plain('bob', 's3cret')).ok).toBe(false);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('rejects a non-PLAIN mechanism', () => {
|
|
125
|
-
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
126
|
-
expect(store.verify('EXTERNAL', plain('alice', 's3cret')).ok).toBe(false);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('rejects a RAW payload', () => {
|
|
130
|
-
const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
|
|
131
|
-
expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('handles multiple loaded accounts', () => {
|
|
135
|
-
const store = new HashedAccountStore([
|
|
136
|
-
hashAccountCredential('alice', 'a-secret'),
|
|
137
|
-
hashAccountCredential('bob', 'b-secret'),
|
|
138
|
-
]);
|
|
139
|
-
expect(store.verify('PLAIN', plain('alice', 'a-secret'))).toEqual({
|
|
140
|
-
ok: true,
|
|
141
|
-
account: 'alice',
|
|
142
|
-
});
|
|
143
|
-
expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual({
|
|
144
|
-
ok: true,
|
|
145
|
-
account: 'bob',
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('fails every verify when constructed with no entries', () => {
|
|
150
|
-
const store = new HashedAccountStore([]);
|
|
151
|
-
expect(store.verify('PLAIN', plain('alice', 's3cret')).ok).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('reports the number of loaded entries via size', () => {
|
|
155
|
-
const store = new HashedAccountStore([
|
|
156
|
-
hashAccountCredential('alice', 'a'),
|
|
157
|
-
hashAccountCredential('bob', 'b'),
|
|
158
|
-
]);
|
|
159
|
-
expect(store.size).toBe(2);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('round-trips an externally-constructed HashedAccountCredential', () => {
|
|
163
|
-
const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
|
|
164
|
-
const store = new HashedAccountStore([row]);
|
|
165
|
-
expect(store.verify('PLAIN', plain('carol', 'p@ss'))).toEqual({
|
|
166
|
-
ok: true,
|
|
167
|
-
account: 'carol',
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
});
|
|
@@ -590,4 +590,9 @@ describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
|
|
|
590
590
|
const store = seededStore();
|
|
591
591
|
expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
|
|
592
592
|
});
|
|
593
|
+
|
|
594
|
+
it('returns undefined for a channel with no recorded messages', () => {
|
|
595
|
+
const store = seededStore();
|
|
596
|
+
expect(store.getMsg('#empty', 'm1')).toBeUndefined();
|
|
597
|
+
});
|
|
593
598
|
});
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { describe, expect, it } from 'vitest';
|
|
13
|
-
import { hashAccountCredential } from '../src/credential-hashing';
|
|
14
13
|
import {
|
|
15
14
|
FakeClock,
|
|
16
15
|
InMemoryServicesStore,
|
|
@@ -265,25 +264,85 @@ describe('PersistentServicesStore — per-mutator ops', () => {
|
|
|
265
264
|
expect(s.batches).toEqual([]);
|
|
266
265
|
});
|
|
267
266
|
|
|
268
|
-
it('
|
|
267
|
+
it('setNickPassword → upsertNick with the re-hashed credential', async () => {
|
|
269
268
|
const s = recordingStore();
|
|
270
|
-
|
|
271
|
-
s.
|
|
269
|
+
s.registerNick('alice', 'hunter2', 'a@x');
|
|
270
|
+
s.setNickPassword('alice', 'newpassword');
|
|
272
271
|
await s.flush();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
272
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
273
|
+
// registerNick enqueues one upsert; setNickPassword enqueues a second.
|
|
274
|
+
expect(upserts).toHaveLength(2);
|
|
275
|
+
const last = upserts[upserts.length - 1];
|
|
276
|
+
if (last?.kind === 'upsertNick') {
|
|
277
|
+
// The plaintext never lands on the queued row.
|
|
278
|
+
expect(JSON.stringify(last.row)).not.toContain('newpassword');
|
|
279
|
+
expect(last.row.credential.algorithm).toBe('scrypt');
|
|
280
|
+
}
|
|
281
|
+
// The in-memory credential now verifies the new password.
|
|
282
|
+
expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
|
|
283
|
+
expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('setNickPassword on an unregistered nick enqueues nothing', async () => {
|
|
287
|
+
const s = recordingStore();
|
|
288
|
+
s.setNickPassword('ghost', 'newpassword');
|
|
289
|
+
await s.flush();
|
|
290
|
+
expect(s.batches).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('addCertFP → upsertNick when a new subject is attached', async () => {
|
|
294
|
+
const s = recordingStore();
|
|
295
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
296
|
+
s.addCertFP('alice', 'CN=alice');
|
|
297
|
+
await s.flush();
|
|
298
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
299
|
+
// registerNick + addCertFP → two upserts.
|
|
300
|
+
expect(upserts).toHaveLength(2);
|
|
301
|
+
const certUpsert = upserts[1];
|
|
302
|
+
if (certUpsert?.kind === 'upsertNick') {
|
|
303
|
+
expect(certUpsert.row.certSubjects).toEqual(['CN=alice']);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('addCertFP on an already-attached subject enqueues nothing (idempotent)', async () => {
|
|
308
|
+
const s = recordingStore();
|
|
309
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
310
|
+
s.addCertFP('alice', 'CN=alice');
|
|
311
|
+
s.addCertFP('alice', 'CN=alice');
|
|
312
|
+
await s.flush();
|
|
313
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
314
|
+
// registerNick + first addCertFP → two upserts. Second addCertFP is a no-op.
|
|
315
|
+
expect(upserts).toHaveLength(2);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('addCertFP on an unregistered nick enqueues nothing', async () => {
|
|
319
|
+
const s = recordingStore();
|
|
320
|
+
s.addCertFP('ghost', 'CN=ghost');
|
|
321
|
+
await s.flush();
|
|
322
|
+
expect(s.batches).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('removeCertFP → upsertNick when a subject is detached', async () => {
|
|
326
|
+
const s = recordingStore();
|
|
327
|
+
s.registerNick('alice', 'pw', 'a@x');
|
|
328
|
+
s.addCertFP('alice', 'CN=alice');
|
|
329
|
+
s.removeCertFP('alice', 'CN=alice');
|
|
330
|
+
await s.flush();
|
|
331
|
+
const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
|
|
332
|
+
// registerNick + addCertFP + removeCertFP → three upserts.
|
|
333
|
+
expect(upserts).toHaveLength(3);
|
|
334
|
+
const last = upserts[2];
|
|
335
|
+
if (last?.kind === 'upsertNick') {
|
|
336
|
+
expect(last.row.certSubjects).toEqual([]);
|
|
278
337
|
}
|
|
279
338
|
});
|
|
280
339
|
|
|
281
|
-
it('
|
|
340
|
+
it('removeCertFP on a non-attached subject enqueues nothing', async () => {
|
|
282
341
|
const s = recordingStore();
|
|
283
342
|
s.registerNick('alice', 'pw', 'a@x');
|
|
284
|
-
|
|
285
|
-
s.ingestAccountCredential(cred);
|
|
343
|
+
s.removeCertFP('alice', 'CN=alice');
|
|
286
344
|
await s.flush();
|
|
345
|
+
// Only the registerNick upsert.
|
|
287
346
|
expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
|
|
288
347
|
});
|
|
289
348
|
|
|
@@ -54,6 +54,11 @@ describe('FakeClock', () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
// RFC 4122 v4 layout: 8-4-4-4-12 hex chars with the version nibble (M) fixed
|
|
58
|
+
// to 4 and the variant nibble (N) constrained to 8-b. Used by the shape and
|
|
59
|
+
// CSPRNG-sourcing tests below.
|
|
60
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
61
|
+
|
|
57
62
|
describe('UuidIdFactory', () => {
|
|
58
63
|
it('implements the IdFactory port', () => {
|
|
59
64
|
const f: IdFactory = new UuidIdFactory();
|
|
@@ -74,6 +79,72 @@ describe('UuidIdFactory', () => {
|
|
|
74
79
|
const f = new UuidIdFactory();
|
|
75
80
|
expect(f.sessionId()).not.toBe(f.sessionId());
|
|
76
81
|
});
|
|
82
|
+
|
|
83
|
+
it('emits RFC 4122 v4-shaped strings from every method', () => {
|
|
84
|
+
const f = new UuidIdFactory();
|
|
85
|
+
expect(f.batchId()).toMatch(UUID_V4_RE);
|
|
86
|
+
expect(f.nonce()).toMatch(UUID_V4_RE);
|
|
87
|
+
expect(f.sessionId()).toMatch(UUID_V4_RE);
|
|
88
|
+
expect(f.traceId()).toMatch(UUID_V4_RE);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not consult Math.random (randomness comes from the CSPRNG)', () => {
|
|
92
|
+
// Regression guard: if a future change reverts to Math.random, mocking it
|
|
93
|
+
// to a constant would make the output deterministic. A CSPRNG-backed
|
|
94
|
+
// factory must not touch Math.random at all.
|
|
95
|
+
const original = Math.random;
|
|
96
|
+
let calls = 0;
|
|
97
|
+
Math.random = () => {
|
|
98
|
+
calls++;
|
|
99
|
+
return 0.5;
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
const f = new UuidIdFactory();
|
|
103
|
+
const a = f.nonce();
|
|
104
|
+
const b = f.sessionId();
|
|
105
|
+
expect(calls).toBe(0);
|
|
106
|
+
expect(a).toMatch(UUID_V4_RE);
|
|
107
|
+
expect(b).toMatch(UUID_V4_RE);
|
|
108
|
+
// With Math.random pinned, a Math.random-backed impl would emit the
|
|
109
|
+
// same value every call; the CSPRNG emits two distinct values.
|
|
110
|
+
expect(a).not.toBe(b);
|
|
111
|
+
} finally {
|
|
112
|
+
Math.random = original;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('produces hex digits indistinguishable from uniform (chi-square, CSPRNG regression guard)', () => {
|
|
117
|
+
// Regression guard against a biased or constant RNG. Counts the trailing
|
|
118
|
+
// 12 hex chars of each id — those bytes are 100% random in a UUIDv4 (the
|
|
119
|
+
// version nibble at index 14 and variant nibble at index 19 are
|
|
120
|
+
// structural and excluded from the bucket count). Buckets the chars by
|
|
121
|
+
// nibble value (0-15) and chi-squares against the uniform expectation.
|
|
122
|
+
//
|
|
123
|
+
// Non-determinism guard: the chi-square statistic for genuinely uniform
|
|
124
|
+
// input follows chi2 with 15 degrees of freedom (mean 15, stddev ~5.5).
|
|
125
|
+
// The chosen threshold (70) sits ~10σ above the mean — P(chi2_15 > 70) ≈
|
|
126
|
+
// 2e-9, i.e. effectively zero false-positives across billions of CI
|
|
127
|
+
// runs. A catastrophic revert (Math.random pinned to a constant) drives
|
|
128
|
+
// chi into the hundreds of millions, so headroom is enormous.
|
|
129
|
+
const f = new UuidIdFactory();
|
|
130
|
+
const N = 10_000;
|
|
131
|
+
const buckets = new Array<number>(16).fill(0);
|
|
132
|
+
for (let i = 0; i < N; i++) {
|
|
133
|
+
// Slice(24) is the trailing 'xxxxxxxxxxxx' group of the UUID string —
|
|
134
|
+
// 12 hex chars, all sourced from getRandomValues.
|
|
135
|
+
const tail = f.nonce().slice(24);
|
|
136
|
+
for (const c of tail) {
|
|
137
|
+
const idx = Number.parseInt(c, 16);
|
|
138
|
+
buckets[idx] = (buckets[idx] ?? 0) + 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const expected = (N * 12) / 16;
|
|
142
|
+
let chi = 0;
|
|
143
|
+
for (const observed of buckets) {
|
|
144
|
+
chi += (observed - expected) ** 2 / expected;
|
|
145
|
+
}
|
|
146
|
+
expect(chi).toBeLessThan(70);
|
|
147
|
+
});
|
|
77
148
|
});
|
|
78
149
|
|
|
79
150
|
describe('SequentialIdFactory', () => {
|