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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB-backed integration tests for the `Accounts` table path.
|
|
3
|
+
* Exercises the real DynamoDB round-trip that pure unit
|
|
4
|
+
* tests cannot: `putAccountCredential` writes a hashed row,
|
|
5
|
+
* `loadDynamoAccountStore` scans it back, and `resolveAccountStore`
|
|
6
|
+
* applies the table-then-config precedence.
|
|
7
|
+
*
|
|
8
|
+
* Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
|
|
9
|
+
* `handlers.test.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
+
import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import {
|
|
16
|
+
loadDynamoAccountStore,
|
|
17
|
+
putAccountCredential,
|
|
18
|
+
resolveAccountStore,
|
|
19
|
+
} from '../src/account-store.js';
|
|
20
|
+
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
21
|
+
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
22
|
+
|
|
23
|
+
const available = process.env.DDB_AVAILABLE === '1';
|
|
24
|
+
const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
25
|
+
|
|
26
|
+
interface Fx {
|
|
27
|
+
tableName: string;
|
|
28
|
+
client: ReturnType<typeof createDynamoDocumentClient>;
|
|
29
|
+
cleanup: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let fx: Fx | null = null;
|
|
33
|
+
|
|
34
|
+
function requireFx(): Fx {
|
|
35
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
36
|
+
return fx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeFx(): Promise<Fx> {
|
|
40
|
+
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
41
|
+
const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
42
|
+
const client = createDynamoDocumentClient({ endpoint });
|
|
43
|
+
const props = TABLE_DEFS.Accounts;
|
|
44
|
+
const pkName = props.partitionKey?.name;
|
|
45
|
+
if (pkName === undefined) throw new Error('Accounts table missing partitionKey');
|
|
46
|
+
await client.send(
|
|
47
|
+
new CreateTableCommand({
|
|
48
|
+
TableName: tableName,
|
|
49
|
+
AttributeDefinitions: [{ AttributeName: pkName, AttributeType: 'S' }],
|
|
50
|
+
KeySchema: [{ AttributeName: pkName, KeyType: 'HASH' }],
|
|
51
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
return {
|
|
55
|
+
tableName,
|
|
56
|
+
client,
|
|
57
|
+
cleanup: async () => {
|
|
58
|
+
try {
|
|
59
|
+
await client.send(new DeleteTableCommand({ TableName: tableName }));
|
|
60
|
+
} catch {
|
|
61
|
+
// Idempotent.
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe.skipIf(!available)('Accounts table round-trip', () => {
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
fx = await makeFx();
|
|
70
|
+
});
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
if (fx) await fx.cleanup();
|
|
73
|
+
fx = null;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
|
|
77
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
|
|
78
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
79
|
+
expect(store).toBeDefined();
|
|
80
|
+
expect(
|
|
81
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
|
|
82
|
+
).toEqual({
|
|
83
|
+
ok: true,
|
|
84
|
+
account: 'alice',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
|
|
89
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
90
|
+
expect(store).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('putAccountCredential writes multiple distinct accounts', async () => {
|
|
94
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
|
|
95
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
|
|
96
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
97
|
+
expect(store).toBeDefined();
|
|
98
|
+
expect(store?.size).toBe(2);
|
|
99
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('putAccountCredential overwrites an existing account (re-seed)', async () => {
|
|
108
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
|
|
109
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
|
|
110
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
111
|
+
expect(store).toBeDefined();
|
|
112
|
+
expect(store?.size).toBe(1);
|
|
113
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
|
|
117
|
+
true,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects a wrong password after round-trip', async () => {
|
|
122
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
|
|
123
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
124
|
+
expect(store).toBeDefined();
|
|
125
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
fx = await makeFx();
|
|
134
|
+
});
|
|
135
|
+
afterEach(async () => {
|
|
136
|
+
if (fx) await fx.cleanup();
|
|
137
|
+
fx = null;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
141
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
|
|
142
|
+
const cfgWithSasl = {
|
|
143
|
+
...DEFAULT_SERVER_CONFIG,
|
|
144
|
+
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
145
|
+
};
|
|
146
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
147
|
+
expect(store).toBeDefined();
|
|
148
|
+
// Table account verifies; config account does NOT (table is authoritative).
|
|
149
|
+
expect(
|
|
150
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
|
|
151
|
+
).toBe(true);
|
|
152
|
+
expect(
|
|
153
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
154
|
+
).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('falls back to the config-seeded store when the table is empty', async () => {
|
|
158
|
+
const cfgWithSasl = {
|
|
159
|
+
...DEFAULT_SERVER_CONFIG,
|
|
160
|
+
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
161
|
+
};
|
|
162
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
163
|
+
expect(store).toBeDefined();
|
|
164
|
+
expect(
|
|
165
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
166
|
+
).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns undefined when the table is empty and no config accounts are set', async () => {
|
|
170
|
+
const store = await resolveAccountStore(
|
|
171
|
+
requireFx().client,
|
|
172
|
+
requireFx().tableName,
|
|
173
|
+
DEFAULT_SERVER_CONFIG,
|
|
174
|
+
);
|
|
175
|
+
expect(store).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns undefined when the table is empty and serverConfig is undefined', async () => {
|
|
179
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
|
|
180
|
+
expect(store).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SERVER_CONFIG,
|
|
4
|
+
type ParsedServerConfig,
|
|
5
|
+
type SaslPayload,
|
|
6
|
+
} from '@serverless-ircd/irc-core';
|
|
7
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
DynamoAccountStore,
|
|
11
|
+
type HashedAccountCredential,
|
|
12
|
+
bindAccountStore,
|
|
13
|
+
hashAccountCredential,
|
|
14
|
+
putAccountCredential,
|
|
15
|
+
resolveAccountStore,
|
|
16
|
+
} from '../src/account-store';
|
|
17
|
+
|
|
18
|
+
function plainPayload(username: string, password: string): SaslPayload {
|
|
19
|
+
return { kind: 'PLAIN', username, password };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Builds a minimal parsed config with the supplied saslAccounts. */
|
|
23
|
+
function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
|
|
24
|
+
return { ...DEFAULT_SERVER_CONFIG, saslAccounts: accounts };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// bindAccountStore — config-seeded fallback
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe('bindAccountStore', () => {
|
|
32
|
+
it('returns an AccountStore that accepts valid PLAIN creds', () => {
|
|
33
|
+
const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
|
|
34
|
+
expect(store).toBeDefined();
|
|
35
|
+
const out = store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
|
|
36
|
+
expect(out).toEqual({ ok: true, account: 'alice' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns undefined when the account list is empty', () => {
|
|
40
|
+
expect(bindAccountStore(configWith([]))).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns undefined when serverConfig is undefined', () => {
|
|
44
|
+
expect(bindAccountStore(undefined)).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects wrong credentials', () => {
|
|
48
|
+
const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
|
|
49
|
+
expect(store).toBeDefined();
|
|
50
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
51
|
+
false,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// hashAccountCredential — pure scrypt hashing helper
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('hashAccountCredential', () => {
|
|
61
|
+
it('produces an entry keyed by the username with scrypt', () => {
|
|
62
|
+
const entry = hashAccountCredential('alice', 'secret');
|
|
63
|
+
expect(entry.account).toBe('alice');
|
|
64
|
+
expect(entry.algorithm).toBe('scrypt');
|
|
65
|
+
expect(entry.salt.length).toBeGreaterThan(0);
|
|
66
|
+
expect(entry.hash.length).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('never stores the plaintext password in any field', () => {
|
|
70
|
+
const entry = hashAccountCredential('alice', 'hunter2-secret');
|
|
71
|
+
expect(entry.account).not.toContain('hunter2');
|
|
72
|
+
expect(entry.salt).not.toContain('hunter2');
|
|
73
|
+
expect(entry.hash).not.toContain('hunter2');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('produces different hashes for the same password under different salts', () => {
|
|
77
|
+
const a = hashAccountCredential('alice', 'secret', { salt: Buffer.from('aaaaaaaaaaaaaaaa') });
|
|
78
|
+
const b = hashAccountCredential('alice', 'secret', { salt: Buffer.from('bbbbbbbbbbbbbbbb') });
|
|
79
|
+
expect(a.hash).not.toBe(b.hash);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('is deterministic for a fixed salt', () => {
|
|
83
|
+
const salt = Buffer.from('1234567890123456');
|
|
84
|
+
const a = hashAccountCredential('alice', 'secret', { salt });
|
|
85
|
+
const b = hashAccountCredential('alice', 'secret', { salt });
|
|
86
|
+
expect(a.hash).toBe(b.hash);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('honours a custom key length', () => {
|
|
90
|
+
const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
|
|
91
|
+
// base64 of 32 bytes → ~44 chars; base64 of 64 bytes → ~88 chars
|
|
92
|
+
expect(Buffer.from(entry.hash, 'base64').length).toBe(32);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// DynamoAccountStore — synchronous verify against pre-loaded hashed entries
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('DynamoAccountStore', () => {
|
|
101
|
+
it('accepts valid PLAIN credentials and returns the account name', () => {
|
|
102
|
+
const entry = hashAccountCredential('alice', 'secret');
|
|
103
|
+
const store = new DynamoAccountStore([entry]);
|
|
104
|
+
expect(store.verify('PLAIN', plainPayload('alice', 'secret'))).toEqual({
|
|
105
|
+
ok: true,
|
|
106
|
+
account: 'alice',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects a wrong password', () => {
|
|
111
|
+
const entry = hashAccountCredential('alice', 'secret');
|
|
112
|
+
const store = new DynamoAccountStore([entry]);
|
|
113
|
+
expect(store.verify('PLAIN', plainPayload('alice', 'wrong')).ok).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects an unknown username', () => {
|
|
117
|
+
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
118
|
+
expect(store.verify('PLAIN', plainPayload('bob', 'secret')).ok).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects a non-PLAIN mechanism', () => {
|
|
122
|
+
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
123
|
+
expect(store.verify('EXTERNAL', plainPayload('alice', 'secret')).ok).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects a RAW payload', () => {
|
|
127
|
+
const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
|
|
128
|
+
expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('handles multiple loaded accounts', () => {
|
|
132
|
+
const store = new DynamoAccountStore([
|
|
133
|
+
hashAccountCredential('alice', 'a-secret'),
|
|
134
|
+
hashAccountCredential('bob', 'b-secret'),
|
|
135
|
+
]);
|
|
136
|
+
expect(store.verify('PLAIN', plainPayload('alice', 'a-secret'))).toEqual({
|
|
137
|
+
ok: true,
|
|
138
|
+
account: 'alice',
|
|
139
|
+
});
|
|
140
|
+
expect(store.verify('PLAIN', plainPayload('bob', 'b-secret'))).toEqual({
|
|
141
|
+
ok: true,
|
|
142
|
+
account: 'bob',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('fails every verify when constructed with no entries', () => {
|
|
147
|
+
const store = new DynamoAccountStore([]);
|
|
148
|
+
expect(store.verify('PLAIN', plainPayload('alice', 'secret')).ok).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('round-trips an externally-constructed HashedAccountCredential', () => {
|
|
152
|
+
const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
|
|
153
|
+
const store = new DynamoAccountStore([row]);
|
|
154
|
+
expect(store.verify('PLAIN', plainPayload('carol', 'p@ss'))).toEqual({
|
|
155
|
+
ok: true,
|
|
156
|
+
account: 'carol',
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports the number of loaded entries via size', () => {
|
|
161
|
+
const store = new DynamoAccountStore([
|
|
162
|
+
hashAccountCredential('alice', 'a'),
|
|
163
|
+
hashAccountCredential('bob', 'b'),
|
|
164
|
+
]);
|
|
165
|
+
expect(store.size).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// resolveAccountStore — table-then-config precedence (mock-based)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe('resolveAccountStore', () => {
|
|
174
|
+
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
175
|
+
const entry = hashAccountCredential('alice', 's3cret');
|
|
176
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
177
|
+
ddb.on(ScanCommand).resolves({
|
|
178
|
+
Items: [entry],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const fallback = bindAccountStore(configWith([{ username: 'envuser', password: 'env-pw' }]));
|
|
182
|
+
const store = await resolveAccountStore(
|
|
183
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
184
|
+
'Accounts',
|
|
185
|
+
fallback === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
|
|
188
|
+
);
|
|
189
|
+
expect(store).toBeDefined();
|
|
190
|
+
expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
|
|
191
|
+
// Config account does NOT verify (table is authoritative).
|
|
192
|
+
expect(store?.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('falls back to config-seeded store when the table is empty', async () => {
|
|
196
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
197
|
+
ddb.on(ScanCommand).resolves({ Items: [] });
|
|
198
|
+
|
|
199
|
+
const store = await resolveAccountStore(
|
|
200
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
201
|
+
'Accounts',
|
|
202
|
+
configWith([{ username: 'bob', password: 'pw' }]),
|
|
203
|
+
);
|
|
204
|
+
expect(store).toBeDefined();
|
|
205
|
+
expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('falls back to config-seeded store when the scan throws', async () => {
|
|
209
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
210
|
+
ddb.on(ScanCommand).rejects(new Error('ResourceNotFoundException'));
|
|
211
|
+
|
|
212
|
+
const store = await resolveAccountStore(
|
|
213
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
214
|
+
'Accounts',
|
|
215
|
+
configWith([{ username: 'bob', password: 'pw' }]),
|
|
216
|
+
);
|
|
217
|
+
expect(store).toBeDefined();
|
|
218
|
+
expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns undefined when table is empty and no config accounts', async () => {
|
|
222
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
223
|
+
ddb.on(ScanCommand).resolves({ Items: [] });
|
|
224
|
+
|
|
225
|
+
const store = await resolveAccountStore(
|
|
226
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
227
|
+
'Accounts',
|
|
228
|
+
undefined,
|
|
229
|
+
);
|
|
230
|
+
expect(store).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// putAccountCredential — CRUD/seed entry point (mock-based)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe('putAccountCredential', () => {
|
|
239
|
+
it('writes a hashed row to DynamoDB and returns the credential', async () => {
|
|
240
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
241
|
+
ddb.on(PutCommand).resolves({});
|
|
242
|
+
|
|
243
|
+
const entry = await putAccountCredential(
|
|
244
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
245
|
+
'Accounts',
|
|
246
|
+
'alice',
|
|
247
|
+
's3cret',
|
|
248
|
+
);
|
|
249
|
+
expect(entry.account).toBe('alice');
|
|
250
|
+
expect(entry.algorithm).toBe('scrypt');
|
|
251
|
+
|
|
252
|
+
const calls = ddb.commandCalls(PutCommand);
|
|
253
|
+
expect(calls).toHaveLength(1);
|
|
254
|
+
const item = calls[0]?.args[0].input.Item as HashedAccountCredential;
|
|
255
|
+
expect(item.account).toBe('alice');
|
|
256
|
+
expect(item.algorithm).toBe('scrypt');
|
|
257
|
+
expect(item.salt).toBe(entry.salt);
|
|
258
|
+
expect(item.hash).toBe(entry.hash);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('the written row round-trips through DynamoAccountStore', async () => {
|
|
262
|
+
const ddb = mockClient(DynamoDBDocumentClient);
|
|
263
|
+
ddb.on(PutCommand).resolves({});
|
|
264
|
+
|
|
265
|
+
const entry = await putAccountCredential(
|
|
266
|
+
ddb as unknown as DynamoDBDocumentClient,
|
|
267
|
+
'Accounts',
|
|
268
|
+
'alice',
|
|
269
|
+
's3cret',
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Simulate reading the row back via DynamoAccountStore.
|
|
273
|
+
const store = new DynamoAccountStore([entry]);
|
|
274
|
+
expect(store.verify('PLAIN', plainPayload('alice', 's3cret'))).toEqual({
|
|
275
|
+
ok: true,
|
|
276
|
+
account: 'alice',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for the AWS `$connect` admission policy
|
|
3
|
+
* (`src/admission.ts`). No DynamoDB — these exercise every branch of
|
|
4
|
+
* {@link decideConnectAdmission} directly so the per-IP branch is covered
|
|
5
|
+
* even though the AWS handler does not yet feed it a `perIp` count.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { decideConnectAdmission } from '../src/admission.js';
|
|
10
|
+
|
|
11
|
+
describe('decideConnectAdmission', () => {
|
|
12
|
+
it('admits when the total is below the cap and no per-IP limit is set', () => {
|
|
13
|
+
const outcome = decideConnectAdmission({ total: 0 }, { maxClients: 2 });
|
|
14
|
+
expect(outcome).toEqual({ admitted: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('admits when the total is exactly one below the cap', () => {
|
|
18
|
+
expect(decideConnectAdmission({ total: 1 }, { maxClients: 2 })).toEqual({
|
|
19
|
+
admitted: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects with 429 once the total reaches the cap', () => {
|
|
24
|
+
expect(decideConnectAdmission({ total: 2 }, { maxClients: 2 })).toEqual({
|
|
25
|
+
admitted: false,
|
|
26
|
+
statusCode: 429,
|
|
27
|
+
reason: 'server full',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects with 429 when the total exceeds the cap', () => {
|
|
32
|
+
expect(decideConnectAdmission({ total: 9999 }, { maxClients: 10 })).toEqual({
|
|
33
|
+
admitted: false,
|
|
34
|
+
statusCode: 429,
|
|
35
|
+
reason: 'server full',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('admits when a per-IP limit is set but no per-IP count is supplied', () => {
|
|
40
|
+
expect(
|
|
41
|
+
decideConnectAdmission({ total: 0 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
|
|
42
|
+
).toEqual({ admitted: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('admits when the per-IP count is below the per-IP limit', () => {
|
|
46
|
+
expect(
|
|
47
|
+
decideConnectAdmission({ total: 0, perIp: 2 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
|
|
48
|
+
).toEqual({ admitted: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects with 429 once the per-IP count reaches the per-IP limit', () => {
|
|
52
|
+
expect(
|
|
53
|
+
decideConnectAdmission({ total: 0, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
|
|
54
|
+
).toEqual({
|
|
55
|
+
admitted: false,
|
|
56
|
+
statusCode: 429,
|
|
57
|
+
reason: 'too many connections from this IP',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('checks the global cap before the per-IP cap', () => {
|
|
62
|
+
expect(
|
|
63
|
+
decideConnectAdmission({ total: 10, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
|
|
64
|
+
).toEqual({
|
|
65
|
+
admitted: false,
|
|
66
|
+
statusCode: 429,
|
|
67
|
+
reason: 'server full',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -57,7 +57,8 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
|
57
57
|
* unavailable so `describe.skipIf` short-circuits before any test runs.
|
|
58
58
|
*/
|
|
59
59
|
export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
|
|
60
|
-
name: 'aws',
|
|
60
|
+
name: 'aws+ws',
|
|
61
|
+
transport: 'ws',
|
|
61
62
|
async create() {
|
|
62
63
|
return new AwsHarness();
|
|
63
64
|
},
|
|
@@ -344,6 +345,17 @@ class AwsClientHandle implements ClientHarness {
|
|
|
344
345
|
await this.actor.receiveTextFrame(`${line}\r\n`);
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
async feed(chunk: string): Promise<void> {
|
|
349
|
+
if (this.actor === undefined) {
|
|
350
|
+
throw new Error(`client ${this.id} has no bound actor`);
|
|
351
|
+
}
|
|
352
|
+
if (this.closed) return;
|
|
353
|
+
// Routes the raw chunk through the bound transport (the WS text-frame
|
|
354
|
+
// transport by default; a future aws+tcp factory via the NLB+Lambda
|
|
355
|
+
// byte-stream path would inject a TcpByteStreamTransport).
|
|
356
|
+
await this.actor.receive(chunk);
|
|
357
|
+
}
|
|
358
|
+
|
|
347
359
|
async waitForLine(
|
|
348
360
|
predicate: (line: string) => boolean,
|
|
349
361
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
@@ -75,6 +75,69 @@ describe('buildLambdaConfigInput', () => {
|
|
|
75
75
|
it('passes QUIT_MESSAGE through', () => {
|
|
76
76
|
expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it('threads SERVER_VERSION into serverVersion', () => {
|
|
80
|
+
expect(buildLambdaConfigInput({ SERVER_VERSION: '1.2.3' }).serverVersion).toBe('1.2.3');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('passes a non-numeric CREATED_AT through verbatim', () => {
|
|
84
|
+
expect(buildLambdaConfigInput({ CREATED_AT: '2024-06-01' }).createdAt).toBe('2024-06-01');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
|
|
88
|
+
expect(buildLambdaConfigInput({ CREATED_AT: '1700000000000' }).createdAt).toBe(
|
|
89
|
+
1_700_000_000_000,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('omits serverVersion/createdAt when their env vars are unset', () => {
|
|
94
|
+
const out = buildLambdaConfigInput({});
|
|
95
|
+
expect('serverVersion' in out).toBe(false);
|
|
96
|
+
expect('createdAt' in out).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
|
|
100
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
|
|
101
|
+
expect(out.saslAccounts).toEqual([
|
|
102
|
+
{ username: 'alice', password: 'secret' },
|
|
103
|
+
{ username: 'bob', password: 'b-pass' },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('omits saslAccounts when SASL_ACCOUNTS is empty', () => {
|
|
108
|
+
expect('saslAccounts' in buildLambdaConfigInput({ SASL_ACCOUNTS: '' })).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('omits saslAccounts when SASL_ACCOUNTS is undefined', () => {
|
|
112
|
+
expect('saslAccounts' in buildLambdaConfigInput({})).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('skips blank lines in SASL_ACCOUNTS (trailing newline tolerance)', () => {
|
|
116
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\n\n \n' });
|
|
117
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('skips SASL_ACCOUNTS entries with no colon separator', () => {
|
|
121
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'nopass\nalice:secret' });
|
|
122
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('skips SASL_ACCOUNTS entries with an empty username (leading colon)', () => {
|
|
126
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ':onlypass\nalice:secret' });
|
|
127
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips SASL_ACCOUNTS entries with an empty password', () => {
|
|
131
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:\nbob:real' });
|
|
132
|
+
expect(out.saslAccounts).toEqual([{ username: 'bob', password: 'real' }]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('trims whole SASL_ACCOUNTS lines but preserves fields around the colon', () => {
|
|
136
|
+
// Only the line is trimmed; whitespace adjacent to ':' is kept (so a
|
|
137
|
+
// stray space does not silently change the stored username/password).
|
|
138
|
+
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
|
|
139
|
+
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
140
|
+
});
|
|
78
141
|
});
|
|
79
142
|
|
|
80
143
|
describe('loadServerConfigFromLambdaEnv', () => {
|
|
@@ -155,4 +218,16 @@ describe('loadServerConfigFromLambdaEnv', () => {
|
|
|
155
218
|
});
|
|
156
219
|
expect(cfg.quitMessage).toBe('goodbye');
|
|
157
220
|
});
|
|
221
|
+
|
|
222
|
+
it('round-trips SASL_ACCOUNTS into cfg.saslAccounts end-to-end', () => {
|
|
223
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
224
|
+
SERVER_NAME: 's',
|
|
225
|
+
NETWORK_NAME: 'n',
|
|
226
|
+
SASL_ACCOUNTS: 'alice:secret\nbob:b-pass',
|
|
227
|
+
});
|
|
228
|
+
expect(cfg.saslAccounts).toEqual([
|
|
229
|
+
{ username: 'alice', password: 'secret' },
|
|
230
|
+
{ username: 'bob', password: 'b-pass' },
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
158
233
|
});
|