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.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,274 +0,0 @@
1
- /**
2
- * D1-backed SASL account store + precedence resolver.
3
- *
4
- * Exercises the real D1 round-trip via miniflare: `putAccountCredential`
5
- * writes a hashed row, `loadD1AccountStore` reads it back, and
6
- * `resolveAccountStore` applies the table-then-config precedence.
7
- */
8
-
9
- import { env } from 'cloudflare:test';
10
- import { hashAccountCredential } from '@serverless-ircd/irc-core';
11
- import { beforeEach, describe, expect, it } from 'vitest';
12
- import {
13
- buildAccountPutStatement,
14
- loadD1AccountStore,
15
- putAccountCredential,
16
- resolveAccountStore,
17
- } from '../src/d1-account-store';
18
-
19
- declare global {
20
- namespace Cloudflare {
21
- interface Env {
22
- ACCOUNTS_DB: D1Database;
23
- }
24
- }
25
- }
26
-
27
- const CREATE_TABLE =
28
- 'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
29
-
30
- async function resetDb(): Promise<void> {
31
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
32
- await env.ACCOUNTS_DB.exec(CREATE_TABLE);
33
- }
34
-
35
- describe('loadD1AccountStore', () => {
36
- beforeEach(async () => {
37
- await resetDb();
38
- });
39
-
40
- it('returns undefined when the table has no rows', async () => {
41
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
42
- expect(store).toBeUndefined();
43
- });
44
-
45
- it('verifies an account after a row is written via putAccountCredential', async () => {
46
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 's3cret');
47
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
48
- expect(store).toBeDefined();
49
- expect(
50
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
51
- ).toEqual({ ok: true, account: 'alice' });
52
- });
53
-
54
- it('rejects a wrong password after round-trip', async () => {
55
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'right');
56
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
57
- expect(store).toBeDefined();
58
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
59
- false,
60
- );
61
- });
62
-
63
- it('loads multiple distinct accounts', async () => {
64
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'a1');
65
- await putAccountCredential(env.ACCOUNTS_DB, 'bob', 'b2');
66
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
67
- expect(store?.size).toBe(2);
68
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
69
- true,
70
- );
71
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
72
- true,
73
- );
74
- });
75
-
76
- it('overwrites an existing account on re-seed', async () => {
77
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'old');
78
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'new');
79
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
80
- expect(store?.size).toBe(1);
81
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
82
- false,
83
- );
84
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
85
- true,
86
- );
87
- });
88
-
89
- it('skips rows missing required columns', async () => {
90
- // Use a permissive table (no NOT NULL) to simulate a row with a missing
91
- // salt — the production schema's NOT NULL prevents this, but the loader
92
- // stays defensive against schema drift / manual edits.
93
- await env.ACCOUNTS_DB.exec('DROP TABLE accounts');
94
- await env.ACCOUNTS_DB.exec(
95
- 'CREATE TABLE accounts (account TEXT PRIMARY KEY, algorithm TEXT, salt TEXT, hash TEXT)',
96
- );
97
- await env.ACCOUNTS_DB.prepare(
98
- "INSERT INTO accounts (account, algorithm, hash) VALUES ('bad', 'scrypt', 'bbbb')",
99
- ).run();
100
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
101
- expect(store).toBeUndefined();
102
- });
103
- });
104
-
105
- describe('putAccountCredential', () => {
106
- beforeEach(async () => {
107
- await resetDb();
108
- });
109
-
110
- it('writes a hashed row that round-trips through the store', async () => {
111
- const entry = await putAccountCredential(env.ACCOUNTS_DB, 'carol', 'p@ss');
112
- expect(entry.account).toBe('carol');
113
- expect(entry.algorithm).toBe('scrypt');
114
-
115
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
116
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
117
- ok: true,
118
- account: 'carol',
119
- });
120
- });
121
-
122
- it('never writes the plaintext password to the row', async () => {
123
- const entry = await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'hunter2-secret');
124
- expect(entry.account).not.toContain('hunter2');
125
- expect(entry.salt).not.toContain('hunter2');
126
- expect(entry.hash).not.toContain('hunter2');
127
- });
128
-
129
- it('matches a manually-hashed entry written via raw D1 insert', async () => {
130
- const hashed = hashAccountCredential('dave', 'pw');
131
- await env.ACCOUNTS_DB.prepare(
132
- 'INSERT INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)',
133
- )
134
- .bind(hashed.account, hashed.algorithm, hashed.salt, hashed.hash)
135
- .run();
136
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
137
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'dave', password: 'pw' }).ok).toBe(
138
- true,
139
- );
140
- });
141
- });
142
-
143
- describe('buildAccountPutStatement (seed SQL builder)', () => {
144
- it('produces an INSERT OR REPLACE statement with scrypt-hashed values', () => {
145
- const { entry, sql } = buildAccountPutStatement('alice', 's3cret');
146
- expect(entry.account).toBe('alice');
147
- expect(entry.algorithm).toBe('scrypt');
148
- expect(sql).toContain('INSERT OR REPLACE INTO accounts');
149
- // Plaintext must never appear in the SQL.
150
- expect(sql).not.toContain('s3cret');
151
- });
152
-
153
- it('the generated SQL round-trips through loadD1AccountStore', async () => {
154
- await resetDb();
155
- const { sql } = buildAccountPutStatement('carol', 'p@ss');
156
- await env.ACCOUNTS_DB.exec(sql);
157
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
158
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
159
- ok: true,
160
- account: 'carol',
161
- });
162
- });
163
-
164
- it('escapes single quotes in the username to avoid breaking the SQL', async () => {
165
- await resetDb();
166
- const { sql } = buildAccountPutStatement("o'brien", 'pw');
167
- await env.ACCOUNTS_DB.exec(sql);
168
- const store = await loadD1AccountStore(env.ACCOUNTS_DB);
169
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: "o'brien", password: 'pw' }).ok).toBe(
170
- true,
171
- );
172
- });
173
-
174
- it('escapes single quotes in the base64 hash output', () => {
175
- // The hash is base64 so it won't contain quotes, but the escaper is
176
- // still exercised — verify the SQL is well-formed.
177
- const { sql } = buildAccountPutStatement('alice', 'secret');
178
- // A valid INSERT with four quoted values.
179
- expect(sql.match(/'/g)?.length).toBe(8);
180
- });
181
- });
182
-
183
- describe('resolveAccountStore precedence', () => {
184
- beforeEach(async () => {
185
- await resetDb();
186
- });
187
-
188
- it('returns the D1 store when the table has rows (table wins)', async () => {
189
- await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'table-secret');
190
- const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
191
- expect(store).toBeDefined();
192
- expect(
193
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
194
- ).toBe(true);
195
- // Config account does NOT verify (table is authoritative).
196
- expect(
197
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
198
- ).toBe(false);
199
- });
200
-
201
- it('falls back to the SASL_ACCOUNTS seed when the table is empty', async () => {
202
- const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
203
- expect(store).toBeDefined();
204
- expect(
205
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
206
- ).toBe(true);
207
- });
208
-
209
- it('returns undefined when the table is empty and no seed is set', async () => {
210
- const store = await resolveAccountStore(env.ACCOUNTS_DB, undefined);
211
- expect(store).toBeUndefined();
212
- });
213
-
214
- it('returns undefined when the table is empty and the seed is blank', async () => {
215
- const store = await resolveAccountStore(env.ACCOUNTS_DB, '');
216
- expect(store).toBeUndefined();
217
- });
218
-
219
- it('falls back to the seed when the D1 binding is undefined', async () => {
220
- const store = await resolveAccountStore(undefined, 'envuser:env-secret');
221
- expect(store).toBeDefined();
222
- expect(
223
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
224
- ).toBe(true);
225
- });
226
- });
227
-
228
- // ---------------------------------------------------------------------------
229
- // resolveAccountStore seed parsing — malformed entries are skipped so a
230
- // trailing newline or partial edit does not break boot.
231
- // ---------------------------------------------------------------------------
232
-
233
- describe('resolveAccountStore — SASL_ACCOUNTS seed edge cases', () => {
234
- it('skips blank lines within the seed', async () => {
235
- const store = await resolveAccountStore(undefined, 'alice:secret\n\nbob:pw\n \n');
236
- expect(store).toBeDefined();
237
- expect(
238
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
239
- ).toBe(true);
240
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'pw' }).ok).toBe(
241
- true,
242
- );
243
- });
244
-
245
- it('skips lines with no colon separator', async () => {
246
- const store = await resolveAccountStore(undefined, 'alice:secret\nnocolon\nbob:pw');
247
- // Only the two well-formed entries are loaded.
248
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'nocolon', password: '' }).ok).toBe(
249
- false,
250
- );
251
- expect(
252
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
253
- ).toBe(true);
254
- });
255
-
256
- it('skips lines where the colon is at position 0', async () => {
257
- const store = await resolveAccountStore(undefined, ':secret\nalice:valid');
258
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'valid' }).ok).toBe(
259
- true,
260
- );
261
- });
262
-
263
- it('skips lines with an empty password', async () => {
264
- const store = await resolveAccountStore(undefined, 'alice:\nbob:valid');
265
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'valid' }).ok).toBe(
266
- true,
267
- );
268
- });
269
-
270
- it('returns undefined when every line is malformed', async () => {
271
- const store = await resolveAccountStore(undefined, '\n \n:no-user\nuser:');
272
- expect(store).toBeUndefined();
273
- });
274
- });
@@ -1,131 +0,0 @@
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
- });