@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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/package.json +7 -3
- package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/api.test.ts +116 -1
- package/src/api/__tests__/batch.integration.test.ts +53 -0
- package/src/api/__tests__/body-limit.test.ts +90 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -21
- package/src/api/routes.ts +47 -1
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
- package/src/db/tenant-db.ts +46 -2
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/build-app-schema.test.ts +25 -0
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/entity-handlers.ts +8 -1
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
- package/src/engine/feature-ast/extractors/events.ts +5 -3
- package/src/engine/feature-ast/extractors/round3.ts +5 -3
- package/src/engine/feature-ast/extractors/round5.ts +5 -4
- package/src/engine/feature-ast/extractors/shared.ts +29 -4
- package/src/engine/feature-ast/patch.ts +48 -21
- package/src/engine/feature-ast/patterns.ts +18 -0
- package/src/engine/feature-ast/render.ts +19 -6
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +97 -1
- package/src/files/file-routes.ts +10 -2
- package/src/files/types.ts +72 -0
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
- package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
- package/src/pipeline/dispatch-batch.ts +11 -5
- package/src/pipeline/dispatch-shared.ts +42 -16
- package/src/pipeline/idempotency.ts +91 -30
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// - Mixed patterns (writeHandler, hook, screen) embed the original
|
|
10
10
|
// source-text of opaque bodies (handler/fn/closure) verbatim via
|
|
11
11
|
// SourceLocation.raw — the renderer doesn't re-print closure code.
|
|
12
|
+
// - Patterns carrying a RawRefSentinel value (e.g. entity/metric/secret
|
|
13
|
+
// `definition`/`options`) or a `*NameRaw` identifier reference
|
|
14
|
+
// (useExtension/defineEvent/extendsRegistrar) fall back to positional-arg
|
|
15
|
+
// form instead of Object-Form, since the raw source text can't be spread
|
|
16
|
+
// into a merged object literal without losing its verbatim-ness.
|
|
12
17
|
// - Comments inside an existing pattern are NOT preserved (Designer
|
|
13
18
|
// edits via forms; for AI generation the output is fresh anyway).
|
|
14
19
|
//
|
|
@@ -306,8 +311,16 @@ function renderReferenceData(p: ReferenceDataPattern): string {
|
|
|
306
311
|
}
|
|
307
312
|
|
|
308
313
|
function renderUseExtension(p: UseExtensionPattern): string {
|
|
314
|
+
const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
|
|
309
315
|
if (isRawRefSentinel(p.options)) {
|
|
310
|
-
return `r.useExtension(${
|
|
316
|
+
return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
|
|
317
|
+
}
|
|
318
|
+
if (p.extensionNameRaw !== undefined) {
|
|
319
|
+
// A raw-ref name can't be spread into the merged Object-Form without
|
|
320
|
+
// losing it to renderValue's plain-string serialization — fall back
|
|
321
|
+
// to positional form, which keeps the reference verbatim (#2111).
|
|
322
|
+
const optionsArg = p.options !== undefined ? `, ${renderValue(p.options)}` : "";
|
|
323
|
+
return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}${optionsArg});`;
|
|
311
324
|
}
|
|
312
325
|
const merged: Record<string, unknown> = {
|
|
313
326
|
name: p.extensionName,
|
|
@@ -506,14 +519,13 @@ function renderMultiStreamProjection(p: MultiStreamProjectionPattern): string {
|
|
|
506
519
|
}
|
|
507
520
|
|
|
508
521
|
function renderDefineEvent(p: DefineEventPattern): string {
|
|
522
|
+
const nameLiteral = p.eventNameRaw ?? JSON.stringify(p.eventName);
|
|
509
523
|
const migrationEntries = p.migrations !== undefined ? Object.entries(p.migrations) : [];
|
|
510
524
|
const hasOptions = p.version !== undefined || migrationEntries.length > 0;
|
|
511
525
|
if (!hasOptions) {
|
|
512
|
-
return `r.defineEvent(${
|
|
526
|
+
return `r.defineEvent(${nameLiteral}, ${p.schemaSource.raw});`;
|
|
513
527
|
}
|
|
514
|
-
const lines: string[] = [
|
|
515
|
-
`r.defineEvent(${JSON.stringify(p.eventName)}, ${p.schemaSource.raw}, {`,
|
|
516
|
-
];
|
|
528
|
+
const lines: string[] = [`r.defineEvent(${nameLiteral}, ${p.schemaSource.raw}, {`];
|
|
517
529
|
if (p.version !== undefined) lines.push(` version: ${p.version},`);
|
|
518
530
|
if (migrationEntries.length > 0) {
|
|
519
531
|
lines.push(" migrations: [");
|
|
@@ -530,7 +542,8 @@ function renderDefineEvent(p: DefineEventPattern): string {
|
|
|
530
542
|
}
|
|
531
543
|
|
|
532
544
|
function renderExtendsRegistrar(p: ExtendsRegistrarPattern): string {
|
|
533
|
-
|
|
545
|
+
const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
|
|
546
|
+
return `r.extendsRegistrar(${nameLiteral}, ${p.defBody.raw});`;
|
|
534
547
|
}
|
|
535
548
|
|
|
536
549
|
function renderEnvSchema(p: EnvSchemaPattern): string {
|
package/src/engine/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import type { FileRoutesOptions } from "../file-routes";
|
|
|
33
33
|
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
34
34
|
import { createLocalProvider } from "../local-provider";
|
|
35
35
|
import type { FileStorageProvider } from "../types";
|
|
36
|
-
import { parseMaxSize, validateFile } from "../types";
|
|
36
|
+
import { parseMaxSize, sniffMimeType, validateFile } from "../types";
|
|
37
37
|
|
|
38
38
|
// UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
|
|
39
39
|
// the query — the row just isn't there. Pre-v1 files-feature tests used
|
|
@@ -199,6 +199,28 @@ describe("file validation", () => {
|
|
|
199
199
|
validateFile({ fileName: "a.jpg", mimeType: "image/jpg", size: 100 }, { accept: ["jpg"] }),
|
|
200
200
|
).toBeNull();
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
test("sniffMimeType recognizes gif, webp and pdf signatures", () => {
|
|
204
|
+
expect(sniffMimeType(new TextEncoder().encode(`GIF89a${"x".repeat(20)}`))).toBe("image/gif");
|
|
205
|
+
const webp = new Uint8Array([
|
|
206
|
+
...new TextEncoder().encode("RIFF"),
|
|
207
|
+
0,
|
|
208
|
+
0,
|
|
209
|
+
0,
|
|
210
|
+
0,
|
|
211
|
+
...new TextEncoder().encode("WEBP"),
|
|
212
|
+
...Array(20).fill(0),
|
|
213
|
+
]);
|
|
214
|
+
expect(sniffMimeType(webp)).toBe("image/webp");
|
|
215
|
+
expect(sniffMimeType(new TextEncoder().encode(`%PDF-1.4${"x".repeat(20)}`))).toBe(
|
|
216
|
+
"application/pdf",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("sniffMimeType returns null for unrecognized or too-short byte sequences", () => {
|
|
221
|
+
expect(sniffMimeType(new Uint8Array([0x00, 0x01, 0x02]))).toBeNull();
|
|
222
|
+
expect(sniffMimeType(new TextEncoder().encode("hello world"))).toBeNull();
|
|
223
|
+
});
|
|
202
224
|
});
|
|
203
225
|
|
|
204
226
|
// --- Integration: Upload → Download → Delete via real HTTP API ---
|
|
@@ -312,6 +334,80 @@ describe("file upload flow via API", () => {
|
|
|
312
334
|
});
|
|
313
335
|
});
|
|
314
336
|
|
|
337
|
+
// --- Serving hardens Content-Type against a spoofed/mismatched declared MIME ---
|
|
338
|
+
|
|
339
|
+
describe("download Content-Type is sniffed from bytes, not trusted from the declared MIME", () => {
|
|
340
|
+
const testPngContent = new Uint8Array([
|
|
341
|
+
0x89,
|
|
342
|
+
0x50,
|
|
343
|
+
0x4e,
|
|
344
|
+
0x47,
|
|
345
|
+
0x0d,
|
|
346
|
+
0x0a,
|
|
347
|
+
0x1a,
|
|
348
|
+
0x0a,
|
|
349
|
+
...Array(50).fill(0),
|
|
350
|
+
]);
|
|
351
|
+
const testJpegContent = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(50).fill(0)]);
|
|
352
|
+
|
|
353
|
+
test("real PNG bytes declared as text/html are served as octet-stream, never text/html", async () => {
|
|
354
|
+
// Bun's own Request#formData() derives File#type from the *filename*
|
|
355
|
+
// extension, not from the multipart part's declared Content-Type header
|
|
356
|
+
// — so the fileName here (.html) is what actually lands as the stored
|
|
357
|
+
// mimeType; the mismatch this test needs is against the real PNG bytes.
|
|
358
|
+
const uploadRes = await uploadFile(adminUser, "sneaky.html", testPngContent, "text/html");
|
|
359
|
+
// Upload itself is not rejected — mismatch enforcement stays at the
|
|
360
|
+
// serving side, existing uploads with an off mimeType keep working.
|
|
361
|
+
expect(uploadRes.status).toBe(201);
|
|
362
|
+
const { id } = await uploadRes.json();
|
|
363
|
+
|
|
364
|
+
const res = await getFile(adminUser, id);
|
|
365
|
+
expect(res.status).toBe(200);
|
|
366
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
367
|
+
expect(res.headers.get("Content-Disposition")).toContain("attachment");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("actual HTML bytes are never served as text/html or image/svg+xml", async () => {
|
|
371
|
+
const htmlBytes = new TextEncoder().encode(
|
|
372
|
+
"<html><body><script>alert(document.domain)</script></body></html>",
|
|
373
|
+
);
|
|
374
|
+
const uploadRes = await uploadFile(adminUser, "evil.html", htmlBytes, "text/html");
|
|
375
|
+
expect(uploadRes.status).toBe(201);
|
|
376
|
+
const { id } = await uploadRes.json();
|
|
377
|
+
|
|
378
|
+
const res = await getFile(adminUser, id);
|
|
379
|
+
expect(res.status).toBe(200);
|
|
380
|
+
expect(res.headers.get("Content-Type")).not.toBe("text/html");
|
|
381
|
+
expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
|
|
382
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
383
|
+
expect(res.headers.get("Content-Disposition")).toContain("attachment");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("an SVG upload — bytes and declared MIME both image/svg+xml — is still downgraded to octet-stream, never served inline", async () => {
|
|
387
|
+
const svgBytes = new TextEncoder().encode(
|
|
388
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
|
389
|
+
);
|
|
390
|
+
const uploadRes = await uploadFile(adminUser, "logo.svg", svgBytes, "image/svg+xml");
|
|
391
|
+
expect(uploadRes.status).toBe(201);
|
|
392
|
+
const { id } = await uploadRes.json();
|
|
393
|
+
|
|
394
|
+
const res = await getFile(adminUser, id);
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
|
|
397
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("regression: a real allowed image type with a correctly declared MIME still serves with the matching sniffed Content-Type", async () => {
|
|
401
|
+
const uploadRes = await uploadFile(adminUser, "photo.jpg", testJpegContent, "image/jpeg");
|
|
402
|
+
expect(uploadRes.status).toBe(201);
|
|
403
|
+
const { id } = await uploadRes.json();
|
|
404
|
+
|
|
405
|
+
const res = await getFile(adminUser, id);
|
|
406
|
+
expect(res.status).toBe(200);
|
|
407
|
+
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
315
411
|
// --- Cross-user access within a tenant (attached file owner-scope) ---
|
|
316
412
|
|
|
317
413
|
describe("attached file owner-scope", () => {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { createFileContext } from "./file-handle";
|
|
|
18
18
|
import { fileRefEntity } from "./file-ref-entity";
|
|
19
19
|
import { fileRefsTable } from "./file-ref-table";
|
|
20
20
|
import type { FileProviderResolver } from "./provider-resolver";
|
|
21
|
-
import { buildStorageKey, validateFile } from "./types";
|
|
21
|
+
import { buildStorageKey, resolveServedContentType, validateFile } from "./types";
|
|
22
22
|
|
|
23
23
|
// Decision returned by a FileAccessGuard — distinct from boolean so callers
|
|
24
24
|
// can't accidentally negate or default it.
|
|
@@ -234,11 +234,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
234
234
|
|
|
235
235
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
236
236
|
const data = await storageProvider.read(fileRef.storageKey);
|
|
237
|
+
// The stored mimeType is client-declared (file.type off the upload) and
|
|
238
|
+
// never verified against the bytes — serving it as-is would let a
|
|
239
|
+
// client label real HTML/SVG as e.g. image/png to slip active content
|
|
240
|
+
// out under a trusted-looking Content-Type. Sniff the actual bytes
|
|
241
|
+
// instead; anything that isn't a known-safe binary signature (including
|
|
242
|
+
// svg/html, which have none) goes out as application/octet-stream.
|
|
243
|
+
const contentType = resolveServedContentType(data, fileRef.mimeType);
|
|
237
244
|
return new Response(Buffer.from(data), {
|
|
238
245
|
headers: {
|
|
239
|
-
"Content-Type":
|
|
246
|
+
"Content-Type": contentType,
|
|
240
247
|
"Content-Disposition": buildContentDispositionHeader(fileRef.fileName),
|
|
241
248
|
"Content-Length": String(fileRef.size),
|
|
249
|
+
"X-Content-Type-Options": "nosniff",
|
|
242
250
|
},
|
|
243
251
|
});
|
|
244
252
|
});
|
package/src/files/types.ts
CHANGED
|
@@ -52,6 +52,78 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
|
|
|
52
52
|
md: ["text/markdown", "text/plain"],
|
|
53
53
|
} satisfies Record<string, readonly string[]>;
|
|
54
54
|
|
|
55
|
+
// Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
|
|
56
|
+
// a reliable binary signature. Used at SERVE time — never trust the stored/
|
|
57
|
+
// client-declared mimeType for Content-Type on its own, sniff the actual
|
|
58
|
+
// bytes instead. Types without a stable signature (svg, txt, csv, json, md)
|
|
59
|
+
// and anything that matches none of these fall back to
|
|
60
|
+
// application/octet-stream in resolveServedContentType below — this also
|
|
61
|
+
// guarantees text/html and image/svg+xml are never served inline, even for
|
|
62
|
+
// an honestly-declared upload.
|
|
63
|
+
const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
|
|
64
|
+
readonly mimeType: string;
|
|
65
|
+
readonly matches: (bytes: Uint8Array) => boolean;
|
|
66
|
+
}> = [
|
|
67
|
+
{
|
|
68
|
+
mimeType: "image/png",
|
|
69
|
+
matches: (bytes) => startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
70
|
+
},
|
|
71
|
+
{ mimeType: "image/jpeg", matches: (bytes) => startsWithBytes(bytes, [0xff, 0xd8, 0xff]) },
|
|
72
|
+
{
|
|
73
|
+
mimeType: "image/gif",
|
|
74
|
+
matches: (bytes) => startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a"),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
mimeType: "image/webp",
|
|
78
|
+
matches: (bytes) => startsWithAscii(bytes, "RIFF") && startsWithAscii(bytes, "WEBP", 8),
|
|
79
|
+
},
|
|
80
|
+
{ mimeType: "application/pdf", matches: (bytes) => startsWithAscii(bytes, "%PDF-") },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function startsWithBytes(bytes: Uint8Array, signature: readonly number[]): boolean {
|
|
84
|
+
if (bytes.length < signature.length) return false;
|
|
85
|
+
return signature.every((byte, i) => bytes[i] === byte);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean {
|
|
89
|
+
if (bytes.length < offset + ascii.length) return false;
|
|
90
|
+
for (let i = 0; i < ascii.length; i++) {
|
|
91
|
+
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function sniffMimeType(bytes: Uint8Array): string | null {
|
|
97
|
+
for (const signature of MAGIC_BYTE_SIGNATURES) {
|
|
98
|
+
if (signature.matches(bytes)) return signature.mimeType;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// image/jpg is not a registered IANA type but EXTENSION_MIME_WHITELIST above
|
|
104
|
+
// accepts it as a jpg alias — sniffMimeType only ever returns the canonical
|
|
105
|
+
// image/jpeg, so without this a legitimately-declared "image/jpg" upload
|
|
106
|
+
// would mismatch its own sniffed type and get needlessly downgraded.
|
|
107
|
+
const DECLARED_MIME_ALIASES: Readonly<Record<string, string>> = {
|
|
108
|
+
"image/jpg": "image/jpeg",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// The Content-Type a served file's bytes are safe to go out with. Bytes that
|
|
112
|
+
// don't sniff as one of the known-safe binary signatures (including
|
|
113
|
+
// svg/html/text formats, which have no reliable magic bytes) always
|
|
114
|
+
// downgrade to application/octet-stream. Bytes that DO sniff safely still
|
|
115
|
+
// downgrade unless the sniffed type matches what was declared at upload —
|
|
116
|
+
// a mismatch (e.g. real PNG bytes uploaded as "text/html") is itself a
|
|
117
|
+
// spoofing signal and is never trusted enough to pick a Content-Type from,
|
|
118
|
+
// even one that would otherwise be harmless.
|
|
119
|
+
export function resolveServedContentType(bytes: Uint8Array, declaredMimeType: string): string {
|
|
120
|
+
const sniffed = sniffMimeType(bytes);
|
|
121
|
+
if (!sniffed) return "application/octet-stream";
|
|
122
|
+
const normalizedDeclared = declaredMimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
123
|
+
const declared = DECLARED_MIME_ALIASES[normalizedDeclared] ?? normalizedDeclared;
|
|
124
|
+
return sniffed === declared ? sniffed : "application/octet-stream";
|
|
125
|
+
}
|
|
126
|
+
|
|
55
127
|
export function validateFile(
|
|
56
128
|
metadata: FileMetadata,
|
|
57
129
|
options: FileValidationOptions,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { egress } from "../egress";
|
|
4
|
+
|
|
5
|
+
// fw#2149 DoD requires proving TLS/SNI validation stays intact against a
|
|
6
|
+
// real HTTPS endpoint, not just a mock — the self-signed-cert tests in
|
|
7
|
+
// egress.test.ts pin the fetch-by-pinned-IP mechanism, this test pins it
|
|
8
|
+
// against a certificate chain issued by a real, publicly trusted CA.
|
|
9
|
+
// example.com is IANA-reserved and kept up for exactly this kind of use.
|
|
10
|
+
const REAL_HOST = "example.com";
|
|
11
|
+
|
|
12
|
+
let networkAvailable = true;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
try {
|
|
16
|
+
await lookup(REAL_HOST);
|
|
17
|
+
} catch {
|
|
18
|
+
networkAvailable = false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("egress external: real HTTPS endpoint", () => {
|
|
23
|
+
test("connects through the pinned IP and validates the real certificate chain", async () => {
|
|
24
|
+
if (!networkAvailable) {
|
|
25
|
+
console.warn(
|
|
26
|
+
`egress real-endpoint test skipped: DNS resolution for ${REAL_HOST} failed (no network in this environment)`,
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fetchIt = egress({ kind: "external" });
|
|
32
|
+
const res = await fetchIt(`https://${REAL_HOST}/`);
|
|
33
|
+
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
expect(await res.text()).toContain("Example Domain");
|
|
36
|
+
});
|
|
37
|
+
});
|