serverless-ircd 0.9.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/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 +305 -0
- package/README.md +134 -41
- 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 +12 -9
- 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 +21 -17
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- 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 +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- 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 +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -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 +3 -6
- 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/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- 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/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- 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 +0 -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 +266 -109
- 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 +95 -54
- 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 +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- 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/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -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 +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- 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 +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -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/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/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,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage-focused tests for {@link ConnectionDO}.
|
|
3
|
+
*
|
|
4
|
+
* Each test pins a residual branch that the main `connection-do.test.ts`
|
|
5
|
+
* suite does not naturally drive:
|
|
6
|
+
*
|
|
7
|
+
* - non-WebSocket `fetch` (426 response)
|
|
8
|
+
* - mTLS cert-subject capture + actor wiring
|
|
9
|
+
* - `webSocketError` event handler
|
|
10
|
+
* - `deliver` over a non-OPEN socket (lazy prune signal)
|
|
11
|
+
* - `sendOutbound` no-op on a non-OPEN socket
|
|
12
|
+
* - `spec-binary` outbound path (UTF-8 binary frames)
|
|
13
|
+
* - `__triggerClose` with a live socket (the `tearDown(ws)` branch)
|
|
14
|
+
* - `disconnect()` RPC on a DO with no socket (the `tearDownNoSocket`
|
|
15
|
+
* branch)
|
|
16
|
+
*
|
|
17
|
+
* Tests run inside real workerd via miniflare so DO hibernation, storage,
|
|
18
|
+
* and RPC behave as in production. Each test gets isolated storage.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { env, runInDurableObject } from 'cloudflare:test';
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
|
|
24
|
+
declare global {
|
|
25
|
+
namespace Cloudflare {
|
|
26
|
+
interface Env {
|
|
27
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type WsMode = 'legacy' | 'spec-text' | 'spec-binary';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Opens a WebSocket to the named ConnectionDO, accepts it, and returns a
|
|
36
|
+
* non-null `WebSocket`. Centralizes the upgrade + null-guard so type
|
|
37
|
+
* narrowing is preserved across awaits in the call sites below.
|
|
38
|
+
*/
|
|
39
|
+
async function openWs(connId: string, protocols?: string): Promise<WebSocket> {
|
|
40
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
41
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
42
|
+
if (protocols !== undefined) {
|
|
43
|
+
headers['Sec-WebSocket-Protocol'] = protocols;
|
|
44
|
+
}
|
|
45
|
+
const response = await stub.fetch('https://do/upgrade', { headers });
|
|
46
|
+
const ws = response.webSocket;
|
|
47
|
+
if (ws === undefined || ws === null) {
|
|
48
|
+
throw new Error('upgrade did not return a WebSocket');
|
|
49
|
+
}
|
|
50
|
+
ws.accept();
|
|
51
|
+
return ws;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal fake WebSocket whose readyState and sent bytes are controlled. */
|
|
55
|
+
class FakeWs {
|
|
56
|
+
constructor(readonly readyState: number) {}
|
|
57
|
+
readonly sent: unknown[] = [];
|
|
58
|
+
closed = false;
|
|
59
|
+
send(data: unknown): void {
|
|
60
|
+
this.sent.push(data);
|
|
61
|
+
}
|
|
62
|
+
close(): void {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('ConnectionDO — fetch rejects non-WebSocket upgrades with 426', () => {
|
|
68
|
+
it('returns 426 Expected WebSocket when no Upgrade header is present', async () => {
|
|
69
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-426'));
|
|
70
|
+
const response = await stub.fetch('https://do/upgrade', {
|
|
71
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
72
|
+
});
|
|
73
|
+
expect(response.status).toBe(426);
|
|
74
|
+
expect(await response.text()).toBe('Expected WebSocket');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('ConnectionDO — mTLS cert subject capture at upgrade', () => {
|
|
79
|
+
it('captures cf.tlsClientAuthCertSubject when present on the request', async () => {
|
|
80
|
+
// The workerd test runtime strips request.cf across the DO RPC
|
|
81
|
+
// boundary, so the inline fetch path can't be exercised directly.
|
|
82
|
+
// We drive the same captureMtlsSubject logic via the test seam.
|
|
83
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-mtls-fetch'));
|
|
84
|
+
const fake = { cf: { tlsClientAuthCertSubject: 'CN=mtls-fetch-alice' } } as unknown as Request;
|
|
85
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
86
|
+
const doi = instance as { __captureMtlsSubjectForTest?: (request: Request) => void };
|
|
87
|
+
doi.__captureMtlsSubjectForTest?.(fake);
|
|
88
|
+
});
|
|
89
|
+
const captured = await runInDurableObject(stub, async (instance: unknown) => {
|
|
90
|
+
const doi = instance as { __peekMtlsCertSubject?: () => Promise<string | undefined> };
|
|
91
|
+
return doi.__peekMtlsCertSubject?.();
|
|
92
|
+
});
|
|
93
|
+
expect(captured).toBe('CN=mtls-fetch-alice');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('ConnectionDO — mTLS cert subject seeds InMemoryMtlsIdentityProvider in buildActor', () => {
|
|
98
|
+
it('constructs the actor with mtlsIdentity when __setMtlsCertSubject is set', async () => {
|
|
99
|
+
// The buildActor path builds an InMemoryMtlsIdentityProvider when
|
|
100
|
+
// mtlsCertSubject is non-undefined. Production captures the subject
|
|
101
|
+
// at upgrade time via request.cf; the workerd test runtime does not
|
|
102
|
+
// surface cf, so we set it via the __setMtlsCertSubject test hook
|
|
103
|
+
// and then drive a frame so buildActor runs.
|
|
104
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-mtls-build'));
|
|
105
|
+
const ws = await openWs('conn-mtls-build');
|
|
106
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
107
|
+
const doi = instance as { __setMtlsCertSubject?: (s: string | undefined) => void };
|
|
108
|
+
doi.__setMtlsCertSubject?.('CN=mtls-alice');
|
|
109
|
+
});
|
|
110
|
+
// Drive a frame so buildActor runs with mtlsCertSubject set. The
|
|
111
|
+
// registration handshake completes; the actor's mtlsIdentity arm
|
|
112
|
+
// has now executed even though no AUTHENTICATE EXTERNAL follows.
|
|
113
|
+
ws.send('NICK mtls-build\r\n');
|
|
114
|
+
ws.send('USER mtls-build 0 * :mtls-build\r\n');
|
|
115
|
+
await new Promise<void>((r) => setTimeout(r, 80));
|
|
116
|
+
ws.close();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('ConnectionDO — webSocketError runs the standard teardown', () => {
|
|
121
|
+
it('invokes tearDown when webSocketError fires and the suppression hook is unset', async () => {
|
|
122
|
+
// The webSocketError handler mirrors webSocketClose: same tearDown
|
|
123
|
+
// path unless suppressed. The platform fires it asynchronously on
|
|
124
|
+
// transport errors; the __triggerWsError test hook calls it
|
|
125
|
+
// deterministically against the DO's own attached socket.
|
|
126
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror'));
|
|
127
|
+
const ws = await openWs('conn-wserror');
|
|
128
|
+
ws.send('NICK wserr-alice\r\n');
|
|
129
|
+
ws.send('USER wserr-alice 0 * :wserr-alice\r\n');
|
|
130
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
131
|
+
|
|
132
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
133
|
+
const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
|
|
134
|
+
await doi.__triggerWsError?.(new Error('synthetic'));
|
|
135
|
+
});
|
|
136
|
+
ws.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('webSocketError is a no-op when __suppressCloseTeardown is set', async () => {
|
|
140
|
+
// The same suppression hook used by webSocketClose also gates
|
|
141
|
+
// webSocketError so a lost-error condition is owned by the alarm.
|
|
142
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror-suppressed'));
|
|
143
|
+
const ws = await openWs('conn-wserror-suppressed');
|
|
144
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
145
|
+
const doi = instance as {
|
|
146
|
+
__suppressCloseTeardownHook?: () => void;
|
|
147
|
+
__triggerWsError?: (error: unknown) => Promise<void>;
|
|
148
|
+
};
|
|
149
|
+
doi.__suppressCloseTeardownHook?.();
|
|
150
|
+
// Must not reject and must not run tearDown (no nick release side
|
|
151
|
+
// effects). The test asserts only that the handler returns
|
|
152
|
+
// cleanly — the coverage tracks that the suppression arm fired.
|
|
153
|
+
await doi.__triggerWsError?.(new Error('synthetic'));
|
|
154
|
+
});
|
|
155
|
+
ws.close();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('ConnectionDO — deliver skips non-OPEN sockets', () => {
|
|
160
|
+
it('returns delivered:0 when the socket is not in OPEN state', async () => {
|
|
161
|
+
// After the client closes, the DO's `ctx.getWebSockets()` may still
|
|
162
|
+
// return the socket transiently; its readyState is CLOSING/CLOSED.
|
|
163
|
+
// The readyState guard in `tryDeliver` skips it, returning false.
|
|
164
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-deliver-closed'));
|
|
165
|
+
const ws = await openWs('conn-deliver-closed');
|
|
166
|
+
ws.close();
|
|
167
|
+
// Give the runtime a tick to propagate the close.
|
|
168
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
169
|
+
const deliverResult = await runInDurableObject(stub, async (instance: unknown) => {
|
|
170
|
+
const doi = instance as {
|
|
171
|
+
deliver?: (lines: Array<{ text: string }>) => Promise<{ delivered: number }>;
|
|
172
|
+
};
|
|
173
|
+
return doi.deliver?.([{ text: ':srv NOTICE * :probe' }]);
|
|
174
|
+
});
|
|
175
|
+
expect(deliverResult).toEqual({ delivered: 0 });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('ConnectionDO — IRCv3 binary.ircv3.net outbound path', () => {
|
|
180
|
+
it('delivers each outbound IRC line as a UTF-8 binary WS message', async () => {
|
|
181
|
+
// spec-binary mode uses TextEncoder + ws.send(Uint8Array) instead
|
|
182
|
+
// of the spec-text path's string send. Opening a binary.ircv3.net
|
|
183
|
+
// connection and completing registration causes the DO to emit
|
|
184
|
+
// several numerics; each is delivered as a binary frame.
|
|
185
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-spec-binary-out'));
|
|
186
|
+
const response = await stub.fetch('https://do/upgrade', {
|
|
187
|
+
headers: { Upgrade: 'websocket', 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
|
|
188
|
+
});
|
|
189
|
+
expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
|
|
190
|
+
const ws = response.webSocket;
|
|
191
|
+
if (ws === undefined || ws === null) {
|
|
192
|
+
throw new Error('upgrade did not return a WebSocket');
|
|
193
|
+
}
|
|
194
|
+
ws.accept();
|
|
195
|
+
const received: ArrayBuffer[] = [];
|
|
196
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
197
|
+
const d = ev.data;
|
|
198
|
+
if (d instanceof ArrayBuffer) received.push(d);
|
|
199
|
+
});
|
|
200
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
201
|
+
ws.send('NICK bin-alice\r\n');
|
|
202
|
+
ws.send('USER bin-alice 0 * :bin-alice\r\n');
|
|
203
|
+
// Wait for the 001 welcome (delivered as a binary frame).
|
|
204
|
+
const start = Date.now();
|
|
205
|
+
while (Date.now() - start < 1500) {
|
|
206
|
+
const lines = received.map((b) => new TextDecoder().decode(b));
|
|
207
|
+
if (lines.some((l) => l.includes(' 001 '))) break;
|
|
208
|
+
await new Promise<void>((r) => setTimeout(r, 30));
|
|
209
|
+
}
|
|
210
|
+
expect(received.length).toBeGreaterThan(0);
|
|
211
|
+
const decoded = received.map((b) => new TextDecoder().decode(b));
|
|
212
|
+
expect(decoded.some((l) => l.includes(' 001 '))).toBe(true);
|
|
213
|
+
ws.close();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('ConnectionDO — __triggerClose with a live socket', () => {
|
|
218
|
+
it('runs tearDown(ws) when a WebSocket is still attached', async () => {
|
|
219
|
+
// The existing connection-do suite exercises __triggerClose only
|
|
220
|
+
// after client.close(), when the socket has already dropped. This
|
|
221
|
+
// case drives the else arm: __triggerClose with a still-live socket
|
|
222
|
+
// invokes tearDown(ws) directly.
|
|
223
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trigger-live'));
|
|
224
|
+
const ws = await openWs('conn-trigger-live');
|
|
225
|
+
ws.send('NICK trigger-alice\r\n');
|
|
226
|
+
ws.send('USER trigger-alice 0 * :trigger-alice\r\n');
|
|
227
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
228
|
+
|
|
229
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
230
|
+
const doi = instance as { __triggerClose?: () => Promise<void> };
|
|
231
|
+
await doi.__triggerClose?.();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// The tearDown path closes the server-side WS; poll readyState
|
|
235
|
+
// rather than relying on the close event reaching the client.
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
while (Date.now() - start < 3000) {
|
|
238
|
+
if (ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) break;
|
|
239
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
240
|
+
}
|
|
241
|
+
expect([WebSocket.CLOSING, WebSocket.CLOSED]).toContain(ws.readyState);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('ConnectionDO — disconnect() RPC with no live socket', () => {
|
|
246
|
+
it('runs tearDownNoSocket when the DO has no attached WebSocket', async () => {
|
|
247
|
+
// ConnectionDO.disconnect is the cross-connection RPC entry point
|
|
248
|
+
// (KICK, operator-driven kill). When the target socket has already
|
|
249
|
+
// dropped (lost close event in production), the no-socket teardown
|
|
250
|
+
// path still releases the nick and fans the QUIT out.
|
|
251
|
+
//
|
|
252
|
+
// We seed a tiny bit of state first (so loadState returns a real
|
|
253
|
+
// record rather than a fresh default), then invoke disconnect with
|
|
254
|
+
// no live socket.
|
|
255
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-disc-no-socket'));
|
|
256
|
+
// Open + close a socket quickly so the DO persists a state record.
|
|
257
|
+
const ws = await openWs('conn-disc-no-socket');
|
|
258
|
+
ws.send('NICK disc-alice\r\n');
|
|
259
|
+
ws.send('USER disc-alice 0 * :disc-alice\r\n');
|
|
260
|
+
await new Promise<void>((r) => setTimeout(r, 50));
|
|
261
|
+
ws.close();
|
|
262
|
+
// Wait for the close to propagate so getWebSockets() returns empty.
|
|
263
|
+
await new Promise<void>((r) => setTimeout(r, 100));
|
|
264
|
+
|
|
265
|
+
// Invoke disconnect via RPC; with no live socket the no-socket
|
|
266
|
+
// path runs.
|
|
267
|
+
const rpc = stub as unknown as { disconnect(reason?: string): Promise<void> };
|
|
268
|
+
await expect(rpc.disconnect('Kicked')).resolves.toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('tearDownNoSocket initializes channelAccess when no prior frame ran', async () => {
|
|
272
|
+
// The `if (this.channelAccess === undefined)` arm in
|
|
273
|
+
// tearDownNoSocket: a never-messaged DO has not run buildActor yet,
|
|
274
|
+
// so channelAccess is undefined and the no-socket teardown seeds
|
|
275
|
+
// it before constructing the actor.
|
|
276
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-disc-fresh-do'));
|
|
277
|
+
const rpc = stub as unknown as { disconnect(reason?: string): Promise<void> };
|
|
278
|
+
await expect(rpc.disconnect('Kicked')).resolves.toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Direct seam tests — exercised with a fake non-OPEN WebSocket so the
|
|
284
|
+
// readyState guards (unreachable via real RPC) are covered.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
describe('ConnectionDO — readyState guards (direct seam)', () => {
|
|
288
|
+
it('deliverToSockets counts only OPEN sockets (mixed list)', async () => {
|
|
289
|
+
// Drives the false arm of the tryDeliver ternary inside the loop:
|
|
290
|
+
// a fake CLOSING socket returns false and the count skips it.
|
|
291
|
+
// The real `ctx.getWebSockets()` only returns OPEN sockets in the
|
|
292
|
+
// test runtime, so this seam is the only way to exercise the
|
|
293
|
+
// mixed case. Both fakes here are non-OPEN so the loop body
|
|
294
|
+
// avoids wsFrameMode (which requires a real hibernated WS).
|
|
295
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-deliver-mixed'));
|
|
296
|
+
const ws = await openWs('conn-deliver-mixed');
|
|
297
|
+
|
|
298
|
+
const result = await runInDurableObject(stub, async (instance: unknown) => {
|
|
299
|
+
const doi = instance as {
|
|
300
|
+
deliverToSockets?: (
|
|
301
|
+
sockets: WebSocket[],
|
|
302
|
+
lines: Array<{ text: string }>,
|
|
303
|
+
) => { delivered: number };
|
|
304
|
+
};
|
|
305
|
+
const a = new FakeWs(WebSocket.CLOSING) as unknown as WebSocket;
|
|
306
|
+
const b = new FakeWs(WebSocket.CLOSED) as unknown as WebSocket;
|
|
307
|
+
return doi.deliverToSockets?.([a, b], [{ text: 'x' }]);
|
|
308
|
+
});
|
|
309
|
+
expect(result).toEqual({ delivered: 0 });
|
|
310
|
+
ws.close();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('tryDeliver returns false and does not write when the socket is not OPEN', async () => {
|
|
314
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trydeliver-closed'));
|
|
315
|
+
const ws = await openWs('conn-trydeliver-closed');
|
|
316
|
+
|
|
317
|
+
const result = await runInDurableObject(stub, async (instance: unknown) => {
|
|
318
|
+
const doi = instance as {
|
|
319
|
+
tryDeliver?: (ws: WebSocket, lines: Array<{ text: string }>) => boolean;
|
|
320
|
+
};
|
|
321
|
+
const fake = new FakeWs(WebSocket.CLOSING) as unknown as WebSocket;
|
|
322
|
+
return doi.tryDeliver?.(fake, [{ text: ':srv NOTICE * :probe' }]);
|
|
323
|
+
});
|
|
324
|
+
expect(result).toBe(false);
|
|
325
|
+
ws.close();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('tryDeliver writes and returns true when the socket is OPEN', async () => {
|
|
329
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-trydeliver-open'));
|
|
330
|
+
const ws = await openWs('conn-trydeliver-open');
|
|
331
|
+
|
|
332
|
+
// The real OPEN WS goes through tryDeliver via the deliver RPC.
|
|
333
|
+
// We exercise the OPEN arm directly via the seam: pass the live WS
|
|
334
|
+
// back into the DO context through runInDurableObject isn't possible
|
|
335
|
+
// (different DO context). Instead, run deliver() against the live
|
|
336
|
+
// socket — that already exercises the OPEN arm of tryDeliver.
|
|
337
|
+
const deliver = stub as unknown as {
|
|
338
|
+
deliver(lines: Array<{ text: string }>): Promise<{ delivered: number }>;
|
|
339
|
+
};
|
|
340
|
+
const deliverResult = await deliver.deliver([{ text: ':srv NOTICE conn-trydeliver-open :hi' }]);
|
|
341
|
+
expect(deliverResult.delivered).toBe(1);
|
|
342
|
+
ws.close();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('sendOutbound early-returns without writing when the socket is not OPEN', async () => {
|
|
346
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-sendoutbound-closed'));
|
|
347
|
+
const ws = await openWs('conn-sendoutbound-closed');
|
|
348
|
+
|
|
349
|
+
const fake = new FakeWs(WebSocket.CLOSED);
|
|
350
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
351
|
+
const doi = instance as {
|
|
352
|
+
__sendOutboundForTest?: (
|
|
353
|
+
ws: WebSocket,
|
|
354
|
+
lines: Array<{ text: string }>,
|
|
355
|
+
mode: WsMode,
|
|
356
|
+
) => void;
|
|
357
|
+
};
|
|
358
|
+
doi.__sendOutboundForTest?.(
|
|
359
|
+
fake as unknown as WebSocket,
|
|
360
|
+
[{ text: ':srv NOTICE * :probe' }],
|
|
361
|
+
'legacy',
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
// Nothing was written (the guard returned before ws.send).
|
|
365
|
+
expect(fake.sent.length).toBe(0);
|
|
366
|
+
ws.close();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('writeDisconnect with a reason writes the ERROR Closing link line', async () => {
|
|
370
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-writedisc-reason'));
|
|
371
|
+
const ws = await openWs('conn-writedisc-reason');
|
|
372
|
+
|
|
373
|
+
// Use a fake OPEN socket so sendOutbound writes through; the
|
|
374
|
+
// __writeDisconnectForTest seam then calls ws.close on the fake.
|
|
375
|
+
const fake = new FakeWs(WebSocket.OPEN);
|
|
376
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
377
|
+
const doi = instance as {
|
|
378
|
+
__writeDisconnectForTest?: (ws: WebSocket, mode: WsMode, reason?: string) => void;
|
|
379
|
+
};
|
|
380
|
+
doi.__writeDisconnectForTest?.(fake as unknown as WebSocket, 'legacy', 'Kicked');
|
|
381
|
+
});
|
|
382
|
+
expect(fake.sent.length).toBe(1);
|
|
383
|
+
expect(String(fake.sent[0])).toContain('ERROR :Closing link: (Kicked)');
|
|
384
|
+
expect(fake.closed).toBe(true);
|
|
385
|
+
ws.close();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('writeDisconnect without a reason skips the ERROR line and still closes', async () => {
|
|
389
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-writedisc-noreason'));
|
|
390
|
+
const ws = await openWs('conn-writedisc-noreason');
|
|
391
|
+
|
|
392
|
+
const fake = new FakeWs(WebSocket.OPEN);
|
|
393
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
394
|
+
const doi = instance as {
|
|
395
|
+
__writeDisconnectForTest?: (ws: WebSocket, mode: WsMode, reason?: string) => void;
|
|
396
|
+
};
|
|
397
|
+
doi.__writeDisconnectForTest?.(fake as unknown as WebSocket, 'legacy');
|
|
398
|
+
});
|
|
399
|
+
expect(fake.sent.length).toBe(0);
|
|
400
|
+
expect(fake.closed).toBe(true);
|
|
401
|
+
ws.close();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('closeIfStillOpen closes an OPEN socket', async () => {
|
|
405
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-closeifopen-open'));
|
|
406
|
+
const ws = await openWs('conn-closeifopen-open');
|
|
407
|
+
|
|
408
|
+
const fake = new FakeWs(WebSocket.OPEN);
|
|
409
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
410
|
+
const doi = instance as { __closeIfStillOpenForTest?: (ws: WebSocket) => void };
|
|
411
|
+
doi.__closeIfStillOpenForTest?.(fake as unknown as WebSocket);
|
|
412
|
+
});
|
|
413
|
+
expect(fake.closed).toBe(true);
|
|
414
|
+
ws.close();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('closeIfStillOpen is a no-op on a CLOSED socket', async () => {
|
|
418
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-closeifopen-closed'));
|
|
419
|
+
const ws = await openWs('conn-closeifopen-closed');
|
|
420
|
+
|
|
421
|
+
const fake = new FakeWs(WebSocket.CLOSED);
|
|
422
|
+
await runInDurableObject(stub, async (instance: unknown) => {
|
|
423
|
+
const doi = instance as { __closeIfStillOpenForTest?: (ws: WebSocket) => void };
|
|
424
|
+
doi.__closeIfStillOpenForTest?.(fake as unknown as WebSocket);
|
|
425
|
+
});
|
|
426
|
+
expect(fake.closed).toBe(false);
|
|
427
|
+
ws.close();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('ConnectionDO — __triggerWsError with no socket', () => {
|
|
432
|
+
it('is a no-op when no WebSocket is attached', async () => {
|
|
433
|
+
// The "no socket attached" branch of __triggerWsError: a never-opened
|
|
434
|
+
// DO has no live WebSocket, so the hook returns without invoking
|
|
435
|
+
// webSocketError. Covers the `if (ws === undefined) return;` guard.
|
|
436
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-wserror-nosocket'));
|
|
437
|
+
await expect(
|
|
438
|
+
runInDurableObject(stub, async (instance: unknown) => {
|
|
439
|
+
const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
|
|
440
|
+
await doi.__triggerWsError?.(new Error('nothing to close'));
|
|
441
|
+
}),
|
|
442
|
+
).resolves.toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('ConnectionDO — flushServices no-op when services are unbound', () => {
|
|
447
|
+
it('does not reject when services is undefined (e.g. binding absent)', async () => {
|
|
448
|
+
// flushServices guards `if (this.services === undefined) return;`.
|
|
449
|
+
// The D1 binding is present in the test env, so the loader resolves
|
|
450
|
+
// a store on the first frame; to exercise the undefined arm we
|
|
451
|
+
// invoke flushServices on a never-touched DO before any frame runs.
|
|
452
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-flush-empty'));
|
|
453
|
+
await expect(
|
|
454
|
+
runInDurableObject(stub, async (instance: unknown) => {
|
|
455
|
+
const doi = instance as { __flushServicesForTest?: () => Promise<void> };
|
|
456
|
+
await doi.__flushServicesForTest?.();
|
|
457
|
+
}),
|
|
458
|
+
).resolves.toBeUndefined();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -6,60 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
expect(parseSaslAccountsEnv('')).toEqual([]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('parses a single well-formed entry', () => {
|
|
21
|
-
expect(parseSaslAccountsEnv('alice:secret')).toEqual([
|
|
22
|
-
{ username: 'alice', password: 'secret' },
|
|
23
|
-
]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('parses multiple newline-delimited entries', () => {
|
|
27
|
-
expect(parseSaslAccountsEnv('alice:secret\nbob:pw')).toEqual([
|
|
28
|
-
{ username: 'alice', password: 'secret' },
|
|
29
|
-
{ username: 'bob', password: 'pw' },
|
|
30
|
-
]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('skips blank lines', () => {
|
|
34
|
-
expect(parseSaslAccountsEnv('alice:secret\n\nbob:pw\n \n')).toEqual([
|
|
35
|
-
{ username: 'alice', password: 'secret' },
|
|
36
|
-
{ username: 'bob', password: 'pw' },
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('skips lines with no colon separator', () => {
|
|
41
|
-
expect(parseSaslAccountsEnv('alice:secret\nnocolonhere\nbob:pw')).toEqual([
|
|
42
|
-
{ username: 'alice', password: 'secret' },
|
|
43
|
-
{ username: 'bob', password: 'pw' },
|
|
44
|
-
]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('skips lines where the colon is at position 0 (empty username)', () => {
|
|
48
|
-
expect(parseSaslAccountsEnv(':secret\nalice:valid')).toEqual([
|
|
49
|
-
{ username: 'alice', password: 'valid' },
|
|
50
|
-
]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('skips lines with an empty password after the colon', () => {
|
|
54
|
-
expect(parseSaslAccountsEnv('alice:\nbob:valid')).toEqual([
|
|
55
|
-
{ username: 'bob', password: 'valid' },
|
|
56
|
-
]);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('returns an empty array when every line is malformed', () => {
|
|
60
|
-
expect(parseSaslAccountsEnv('\n \n:no-user\nuser:')).toEqual([]);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
9
|
+
import {
|
|
10
|
+
NO_OP_DISCONNECT,
|
|
11
|
+
NO_OP_SEND,
|
|
12
|
+
buildOptionalActorFields,
|
|
13
|
+
chooseMotdProvider,
|
|
14
|
+
extractMtlsSubject,
|
|
15
|
+
wsFrameModeFromTags,
|
|
16
|
+
} from '../src/connection-do';
|
|
63
17
|
|
|
64
18
|
describe('wsFrameModeFromTags', () => {
|
|
65
19
|
it("returns 'legacy' when no tags are present", () => {
|
|
@@ -219,3 +173,90 @@ describe('PassthroughChannelAccess.refreshChannel', () => {
|
|
|
219
173
|
expect(chan.topic?.text).toBe('keep');
|
|
220
174
|
});
|
|
221
175
|
});
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// chooseMotdProvider — default vs. populated MOTD_LINES branch
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
describe('chooseMotdProvider', () => {
|
|
182
|
+
it('returns the default MOTD when the env value is undefined', () => {
|
|
183
|
+
const provider = chooseMotdProvider(undefined);
|
|
184
|
+
expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns the default MOTD when the env value is an empty string', () => {
|
|
188
|
+
const provider = chooseMotdProvider('');
|
|
189
|
+
expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('splits a populated MOTD_LINES on newlines', () => {
|
|
193
|
+
const provider = chooseMotdProvider('Line one\nLine two\nLine three');
|
|
194
|
+
expect(provider.lines()).toEqual(['Line one', 'Line two', 'Line three']);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ============================================================================
|
|
199
|
+
// buildOptionalActorFields — conditional spread branches
|
|
200
|
+
// ============================================================================
|
|
201
|
+
|
|
202
|
+
describe('buildOptionalActorFields', () => {
|
|
203
|
+
it('returns an empty object when every optional is undefined', () => {
|
|
204
|
+
expect(buildOptionalActorFields(undefined, undefined)).toEqual({});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('includes `services` when defined', () => {
|
|
208
|
+
const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[0];
|
|
209
|
+
expect(buildOptionalActorFields(fake, undefined).services).toBe(fake);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('includes `mtlsIdentity` when defined', () => {
|
|
213
|
+
const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[1];
|
|
214
|
+
expect(buildOptionalActorFields(undefined, fake).mtlsIdentity).toBe(fake);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// NO_OP_SEND / NO_OP_DISCONNECT — invoked once to cover the function bodies
|
|
220
|
+
// (the no-socket teardown path constructs these handlers but the actor never
|
|
221
|
+
// invokes send/disconnect during QUIT processing — there is no peer to
|
|
222
|
+
// deliver to and the socket is already gone).
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
|
|
226
|
+
it('NO_OP_SEND runs without throwing and returns undefined', () => {
|
|
227
|
+
expect(NO_OP_SEND([{ text: 'ignored' }])).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('NO_OP_DISCONNECT runs without throwing and returns undefined', () => {
|
|
231
|
+
expect(NO_OP_DISCONNECT('any reason')).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// extractMtlsSubject — request.cf.tlsClientAuthCertSubject capture
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
describe('extractMtlsSubject', () => {
|
|
240
|
+
it('returns the subject when cf.tlsClientAuthCertSubject is a string', () => {
|
|
241
|
+
// The CF edge injects request.cf on every proxied request. We can't
|
|
242
|
+
// mutate Request.cf in workerd tests (it's read-only), so the
|
|
243
|
+
// upgrade-time capture is exercised via this pure seam.
|
|
244
|
+
const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
|
|
245
|
+
expect(extractMtlsSubject(fake)).toBe('CN=alice');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns undefined when cf is absent', () => {
|
|
249
|
+
const fake = { cf: undefined } as unknown as Request;
|
|
250
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns undefined when cf is present but lacks tlsClientAuthCertSubject', () => {
|
|
254
|
+
const fake = { cf: { country: 'US' } } as unknown as Request;
|
|
255
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('returns undefined when tlsClientAuthCertSubject is not a string', () => {
|
|
259
|
+
const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
|
|
260
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
});
|