@decohere/http 0.0.1-dev.126 → 0.0.1-dev.5
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/README.md +2 -2
- package/package.json +4 -13
- package/src/app/app.ts +147 -0
- package/src/app/module.ts +19 -0
- package/src/auth/ability.ts +56 -0
- package/src/auth/auth.config.ts +34 -0
- package/src/auth/auth.extensions.ts +115 -0
- package/src/auth/auth.types.ts +124 -0
- package/src/auth/casl-authorization.service.ts +62 -0
- package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
- package/src/auth/safe-outbound-url.ts +117 -0
- package/src/auth/token-service.ts +192 -0
- package/src/builder/builder.ts +118 -0
- package/{es6/src/const.js → src/const.ts} +25 -17
- package/src/health/health.controller.ts +34 -0
- package/src/health/health.extensions.ts +53 -0
- package/src/health/readiness.service.ts +33 -0
- package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
- package/src/hosting/hosted-service-host.ts +102 -0
- package/src/hosting/hosted-service.ts +31 -0
- package/src/http/http.compression.ts +38 -0
- package/src/http/http.extensions.ts +244 -0
- package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
- package/src/http/http.options.ts +41 -0
- package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
- package/src/http/http.static.ts +359 -0
- package/{es6/src/index.js → src/index.ts} +1 -0
- package/src/openapi/index.ts +22 -0
- package/src/openapi/openapi.generator.ts +78 -0
- package/src/openapi/openapi.types.ts +60 -0
- package/src/openapi/request-schema.ts +155 -0
- package/src/openapi/request-validator.ts +95 -0
- package/src/providers/fastify.provider.ts +454 -0
- package/src/routing/anvil.types.ts +150 -0
- package/src/routing/decorators/argument.decorator.ts +33 -0
- package/src/routing/decorators/auth.decorator.ts +79 -0
- package/src/routing/decorators/controller.decorator.ts +9 -0
- package/src/routing/decorators/field.decorator.ts +15 -0
- package/src/routing/decorators/request.decorator.ts +34 -0
- package/src/routing/decorators/response.decorator.ts +18 -0
- package/src/routing/decorators/route.decorator.ts +52 -0
- package/src/routing/filters/exception-filter-host.ts +90 -0
- package/src/routing/filters/exception-filter.ts +29 -0
- package/src/routing/middleware/http-access-log.middleware.ts +36 -0
- package/src/routing/middleware/middleware.ts +26 -0
- package/src/routing/middleware/request-id.ts +18 -0
- package/src/routing/path.util.ts +38 -0
- package/src/routing/pipes/pipes.ts +132 -0
- package/src/routing/services/route-argument-resolver.ts +92 -0
- package/src/routing/services/route-registry.ts +229 -0
- package/src/routing/services/router.ts +166 -0
- package/src/sse/sse.decorator.ts +41 -0
- package/src/sse/sse.encoder.ts +86 -0
- package/src/sse/sse.types.ts +47 -0
- package/src/sse/sse.wire.ts +34 -0
- package/src/testing/boot-module.ts +63 -0
- package/src/testing/sse-reader.ts +120 -0
- package/tests/app.hosted-services.spec.ts +292 -0
- package/tests/app.shutdown.integration.spec.ts +160 -0
- package/tests/app.spec.ts +132 -0
- package/tests/auth.ability.spec.ts +48 -0
- package/tests/auth.decorators.spec.ts +20 -0
- package/tests/auth.fail-closed.pentest.spec.ts +77 -0
- package/tests/auth.integration.spec.ts +234 -0
- package/tests/auth.jwks-cache.spec.ts +52 -0
- package/tests/auth.missing-auth.pentest.spec.ts +56 -0
- package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
- package/tests/auth.oidc.integration.spec.ts +161 -0
- package/tests/auth.token-service.spec.ts +251 -0
- package/tests/builder.spec.ts +12 -0
- package/tests/health.integration.spec.ts +111 -0
- package/tests/helpers/listen-ephemeral.ts +13 -0
- package/tests/helpers/local-oidc-server.ts +100 -0
- package/tests/http.access-log.integration.spec.ts +111 -0
- package/tests/http.boolean-query.integration.spec.ts +59 -0
- package/tests/http.compression.integration.spec.ts +604 -0
- package/tests/http.date-query.integration.spec.ts +71 -0
- package/tests/http.global-prefix.integration.spec.ts +284 -0
- package/tests/http.number-query.integration.spec.ts +65 -0
- package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
- package/tests/http.pipeline-order.integration.spec.ts +95 -0
- package/tests/http.request-cleanup.integration.spec.ts +57 -0
- package/tests/http.request-id-scope.pentest.spec.ts +50 -0
- package/tests/http.security.integration.spec.ts +106 -0
- package/tests/http.static.integration.spec.ts +847 -0
- package/tests/http.static.spec.ts +111 -0
- package/tests/http.surface.integration.spec.ts +167 -0
- package/tests/openapi.integration.spec.ts +193 -0
- package/tests/openapi.validator-cache.integration.spec.ts +55 -0
- package/tests/routing.decorators.spec.ts +15 -0
- package/tests/routing.registry.spec.ts +44 -0
- package/tests/routing.resolver.spec.ts +92 -0
- package/tests/sse.integration.spec.ts +421 -0
- package/tests/sse.wire.spec.ts +33 -0
- package/LICENSE +0 -15
- package/commonjs/src/app/app.js +0 -166
- package/commonjs/src/app/index.js +0 -19
- package/commonjs/src/app/module.js +0 -17
- package/commonjs/src/auth/ability.js +0 -54
- package/commonjs/src/auth/auth.config.js +0 -23
- package/commonjs/src/auth/auth.extensions.js +0 -106
- package/commonjs/src/auth/auth.types.js +0 -65
- package/commonjs/src/auth/casl-authorization.service.js +0 -36
- package/commonjs/src/auth/index.js +0 -23
- package/commonjs/src/auth/principal-provider.js +0 -19
- package/commonjs/src/auth/safe-outbound-url.js +0 -102
- package/commonjs/src/auth/token-service.js +0 -151
- package/commonjs/src/builder/builder.js +0 -95
- package/commonjs/src/builder/index.js +0 -18
- package/commonjs/src/const.js +0 -87
- package/commonjs/src/health/health.controller.js +0 -75
- package/commonjs/src/health/health.extensions.js +0 -50
- package/commonjs/src/health/index.js +0 -21
- package/commonjs/src/health/readiness.service.js +0 -67
- package/commonjs/src/health/readiness.types.js +0 -7
- package/commonjs/src/hosting/hosted-service-host.js +0 -88
- package/commonjs/src/hosting/hosted-service.js +0 -11
- package/commonjs/src/hosting/index.js +0 -19
- package/commonjs/src/http/http.compression.js +0 -27
- package/commonjs/src/http/http.extensions.js +0 -269
- package/commonjs/src/http/http.options.js +0 -26
- package/commonjs/src/http/http.options.store.js +0 -20
- package/commonjs/src/http/http.security.js +0 -27
- package/commonjs/src/http/http.static.js +0 -248
- package/commonjs/src/http/index.js +0 -22
- package/commonjs/src/index.js +0 -27
- package/commonjs/src/openapi/index.js +0 -45
- package/commonjs/src/openapi/openapi.generator.js +0 -103
- package/commonjs/src/openapi/openapi.types.js +0 -4
- package/commonjs/src/openapi/request-schema.js +0 -187
- package/commonjs/src/openapi/request-validator.js +0 -85
- package/commonjs/src/providers/fastify.provider.js +0 -391
- package/commonjs/src/providers/index.js +0 -18
- package/commonjs/src/routing/anvil.types.js +0 -99
- package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
- package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
- package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
- package/commonjs/src/routing/decorators/field.decorator.js +0 -25
- package/commonjs/src/routing/decorators/index.js +0 -24
- package/commonjs/src/routing/decorators/request.decorator.js +0 -30
- package/commonjs/src/routing/decorators/response.decorator.js +0 -34
- package/commonjs/src/routing/decorators/route.decorator.js +0 -67
- package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
- package/commonjs/src/routing/filters/exception-filter.js +0 -28
- package/commonjs/src/routing/index.js +0 -25
- package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
- package/commonjs/src/routing/middleware/middleware.js +0 -20
- package/commonjs/src/routing/middleware/request-id.js +0 -30
- package/commonjs/src/routing/path.util.js +0 -51
- package/commonjs/src/routing/pipes/index.js +0 -18
- package/commonjs/src/routing/pipes/pipes.js +0 -145
- package/commonjs/src/routing/services/index.js +0 -18
- package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
- package/commonjs/src/routing/services/route-registry.js +0 -194
- package/commonjs/src/routing/services/router.js +0 -158
- package/commonjs/src/sse/index.js +0 -26
- package/commonjs/src/sse/sse.decorator.js +0 -42
- package/commonjs/src/sse/sse.encoder.js +0 -73
- package/commonjs/src/sse/sse.types.js +0 -36
- package/commonjs/src/sse/sse.wire.js +0 -40
- package/commonjs/src/testing/boot-module.js +0 -38
- package/commonjs/src/testing/index.js +0 -20
- package/commonjs/src/testing/sse-reader.js +0 -98
- package/es6/src/app/app.js +0 -148
- package/es6/src/app/index.js +0 -2
- package/es6/src/app/module.js +0 -7
- package/es6/src/auth/ability.js +0 -45
- package/es6/src/auth/auth.config.js +0 -19
- package/es6/src/auth/auth.extensions.js +0 -83
- package/es6/src/auth/auth.types.js +0 -35
- package/es6/src/auth/casl-authorization.service.js +0 -40
- package/es6/src/auth/index.js +0 -6
- package/es6/src/auth/principal-provider.js +0 -14
- package/es6/src/auth/safe-outbound-url.js +0 -99
- package/es6/src/auth/token-service.js +0 -141
- package/es6/src/builder/builder.js +0 -85
- package/es6/src/builder/index.js +0 -1
- package/es6/src/health/health.controller.js +0 -65
- package/es6/src/health/health.extensions.js +0 -40
- package/es6/src/health/index.js +0 -4
- package/es6/src/health/readiness.service.js +0 -57
- package/es6/src/health/readiness.types.js +0 -4
- package/es6/src/hosting/hosted-service-host.js +0 -81
- package/es6/src/hosting/hosted-service.js +0 -8
- package/es6/src/hosting/index.js +0 -2
- package/es6/src/http/http.compression.js +0 -9
- package/es6/src/http/http.extensions.js +0 -259
- package/es6/src/http/http.options.js +0 -16
- package/es6/src/http/http.options.store.js +0 -13
- package/es6/src/http/http.security.js +0 -9
- package/es6/src/http/http.static.js +0 -224
- package/es6/src/http/index.js +0 -5
- package/es6/src/openapi/index.js +0 -3
- package/es6/src/openapi/openapi.generator.js +0 -82
- package/es6/src/openapi/openapi.types.js +0 -1
- package/es6/src/openapi/request-schema.js +0 -160
- package/es6/src/openapi/request-validator.js +0 -71
- package/es6/src/providers/fastify.provider.js +0 -376
- package/es6/src/providers/index.js +0 -1
- package/es6/src/routing/anvil.types.js +0 -57
- package/es6/src/routing/decorators/argument.decorator.js +0 -28
- package/es6/src/routing/decorators/auth.decorator.js +0 -58
- package/es6/src/routing/decorators/controller.decorator.js +0 -6
- package/es6/src/routing/decorators/field.decorator.js +0 -15
- package/es6/src/routing/decorators/index.js +0 -7
- package/es6/src/routing/decorators/request.decorator.js +0 -24
- package/es6/src/routing/decorators/response.decorator.js +0 -16
- package/es6/src/routing/decorators/route.decorator.js +0 -37
- package/es6/src/routing/filters/exception-filter-host.js +0 -127
- package/es6/src/routing/filters/exception-filter.js +0 -10
- package/es6/src/routing/index.js +0 -8
- package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
- package/es6/src/routing/middleware/middleware.js +0 -10
- package/es6/src/routing/middleware/request-id.js +0 -14
- package/es6/src/routing/path.util.js +0 -33
- package/es6/src/routing/pipes/index.js +0 -1
- package/es6/src/routing/pipes/pipes.js +0 -118
- package/es6/src/routing/services/index.js +0 -1
- package/es6/src/routing/services/route-argument-resolver.js +0 -67
- package/es6/src/routing/services/route-registry.js +0 -184
- package/es6/src/routing/services/router.js +0 -148
- package/es6/src/sse/index.js +0 -2
- package/es6/src/sse/sse.decorator.js +0 -31
- package/es6/src/sse/sse.encoder.js +0 -67
- package/es6/src/sse/sse.types.js +0 -22
- package/es6/src/sse/sse.wire.js +0 -30
- package/es6/src/testing/boot-module.js +0 -35
- package/es6/src/testing/index.js +0 -2
- package/es6/src/testing/sse-reader.js +0 -91
- package/types/src/app/app.d.ts +0 -41
- package/types/src/app/module.d.ts +0 -15
- package/types/src/auth/ability.d.ts +0 -19
- package/types/src/auth/auth.config.d.ts +0 -28
- package/types/src/auth/auth.extensions.d.ts +0 -53
- package/types/src/auth/auth.types.d.ts +0 -73
- package/types/src/auth/casl-authorization.service.d.ts +0 -28
- package/types/src/auth/safe-outbound-url.d.ts +0 -9
- package/types/src/auth/token-service.d.ts +0 -31
- package/types/src/builder/builder.d.ts +0 -36
- package/types/src/const.d.ts +0 -32
- package/types/src/health/health.controller.d.ts +0 -16
- package/types/src/health/health.extensions.d.ts +0 -31
- package/types/src/health/readiness.service.d.ts +0 -9
- package/types/src/hosting/hosted-service-host.d.ts +0 -37
- package/types/src/hosting/hosted-service.d.ts +0 -31
- package/types/src/http/http.compression.d.ts +0 -32
- package/types/src/http/http.extensions.d.ts +0 -79
- package/types/src/http/http.options.d.ts +0 -39
- package/types/src/http/http.static.d.ts +0 -160
- package/types/src/index.d.ts +0 -10
- package/types/src/openapi/index.d.ts +0 -6
- package/types/src/openapi/openapi.generator.d.ts +0 -6
- package/types/src/openapi/openapi.types.d.ts +0 -58
- package/types/src/openapi/request-schema.d.ts +0 -21
- package/types/src/openapi/request-validator.d.ts +0 -9
- package/types/src/providers/fastify.provider.d.ts +0 -56
- package/types/src/routing/anvil.types.d.ts +0 -119
- package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
- package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
- package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
- package/types/src/routing/decorators/field.decorator.d.ts +0 -2
- package/types/src/routing/decorators/request.decorator.d.ts +0 -8
- package/types/src/routing/decorators/response.decorator.d.ts +0 -4
- package/types/src/routing/decorators/route.decorator.d.ts +0 -7
- package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
- package/types/src/routing/filters/exception-filter.d.ts +0 -16
- package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
- package/types/src/routing/middleware/middleware.d.ts +0 -10
- package/types/src/routing/middleware/request-id.d.ts +0 -6
- package/types/src/routing/path.util.d.ts +0 -9
- package/types/src/routing/pipes/pipes.d.ts +0 -21
- package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
- package/types/src/routing/services/route-registry.d.ts +0 -20
- package/types/src/routing/services/router.d.ts +0 -22
- package/types/src/sse/sse.decorator.d.ts +0 -17
- package/types/src/sse/sse.encoder.d.ts +0 -16
- package/types/src/sse/sse.types.d.ts +0 -37
- package/types/src/sse/sse.wire.d.ts +0 -9
- package/types/src/testing/boot-module.d.ts +0 -34
- package/types/src/testing/sse-reader.d.ts +0 -29
- /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
- /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
- /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
- /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
- /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
- /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
- /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
- /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
- /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
- /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
- /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
- /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
- /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type IConfigurationService,
|
|
7
|
+
ConfigurationServiceToken,
|
|
8
|
+
} from "@decohere/core/configuration";
|
|
9
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
10
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { AuthConfig } from "../src/auth/auth.config.js";
|
|
12
|
+
import "../src/auth/auth.extensions.js";
|
|
13
|
+
import { TokenService } from "../src/auth/token-service.js";
|
|
14
|
+
import type { StaticOptions } from "../src/http/http.static.js";
|
|
15
|
+
import { AllowAnonymous, Authorize, Controller, Get } from "../src/index.js";
|
|
16
|
+
import type { OpenApiDocument } from "../src/openapi/openapi.types.js";
|
|
17
|
+
import { bootModule } from "../src/testing/index.js";
|
|
18
|
+
|
|
19
|
+
// The suite's fs is an in-memory fake. Static files are read by the real server from real disk.
|
|
20
|
+
vi.unmock("fs");
|
|
21
|
+
vi.unmock("fs/promises");
|
|
22
|
+
vi.unmock("node:fs");
|
|
23
|
+
vi.unmock("node:fs/promises");
|
|
24
|
+
|
|
25
|
+
const secretConfig = {
|
|
26
|
+
secret: "super-secret-key-that-is-long-enough-for-hs256",
|
|
27
|
+
issuer: "test-issuer",
|
|
28
|
+
audience: "test-audience",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class SecretConfigService implements IConfigurationService {
|
|
32
|
+
public get dataSources() {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
|
|
36
|
+
if (type === AuthConfig)
|
|
37
|
+
return Object.assign(new AuthConfig(), secretConfig) as unknown as T;
|
|
38
|
+
return {} as T;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Controller({ prefix: "/api" })
|
|
43
|
+
class SecuredController {
|
|
44
|
+
@Authorize("read", "Article")
|
|
45
|
+
@Get("/articles")
|
|
46
|
+
public articles() {
|
|
47
|
+
return { secret: true };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Controller({ prefix: "/api" })
|
|
52
|
+
class ApiController {
|
|
53
|
+
@Get("/ping")
|
|
54
|
+
public ping() {
|
|
55
|
+
return { pong: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a browser sends when it navigates: the SPA fallback only answers this kind of request. */
|
|
60
|
+
const browser = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
|
|
61
|
+
|
|
62
|
+
@Controller({ prefix: "/lp" })
|
|
63
|
+
class PortfolioController {
|
|
64
|
+
@AllowAnonymous()
|
|
65
|
+
@Get("/portfolio")
|
|
66
|
+
public portfolio() {
|
|
67
|
+
return { positions: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let site: string;
|
|
72
|
+
let ui: string;
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
site = await mkdtemp(path.join(tmpdir(), "http-static-"));
|
|
76
|
+
ui = path.join(site, "ui");
|
|
77
|
+
await mkdir(path.join(ui, "assets"), { recursive: true });
|
|
78
|
+
await mkdir(path.join(ui, "empty-dir"), { recursive: true });
|
|
79
|
+
await mkdir(path.join(ui, ".hidden"), { recursive: true });
|
|
80
|
+
await writeFile(path.join(ui, "index.html"), "<!doctype html><title>the app</title>");
|
|
81
|
+
await writeFile(path.join(ui, "assets/index-DiwrgTda.js"), "console.log('app')");
|
|
82
|
+
await writeFile(path.join(ui, "assets/style.3f9a8c1b.css"), "body{}");
|
|
83
|
+
await writeFile(path.join(ui, "assets/logo-background.png"), "png");
|
|
84
|
+
await writeFile(path.join(ui, "remoteEntry.js"), "export default {}");
|
|
85
|
+
await writeFile(path.join(ui, "favicon.ico"), "ico");
|
|
86
|
+
await writeFile(path.join(ui, ".env"), "SECRET=1");
|
|
87
|
+
await writeFile(path.join(ui, ".hidden/key.txt"), "key");
|
|
88
|
+
// Beside the served folder, never to be reachable through it.
|
|
89
|
+
await writeFile(path.join(site, "secret.txt"), "outside the root");
|
|
90
|
+
await writeFile(path.join(site, "ui-sibling.txt"), "sibling");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(async () => {
|
|
94
|
+
await rm(site, { recursive: true, force: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* These tests are about caching, paths and security, not about encodings, and most of them read
|
|
99
|
+
* only the headers. A streamed file that is compressed (the HTTP package compresses responses, see
|
|
100
|
+
* http.compression.integration.spec.ts) stays open until the client reads it, and an unread body
|
|
101
|
+
* would hold up stopping the app. So ask for the plain file; the compression suite covers the rest.
|
|
102
|
+
*/
|
|
103
|
+
const fetch: typeof globalThis.fetch = (input, init) =>
|
|
104
|
+
globalThis.fetch(input, {
|
|
105
|
+
...init,
|
|
106
|
+
headers: { "accept-encoding": "identity", ...(init?.headers as Record<string, string>) },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
vi.unstubAllEnvs();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function boot(
|
|
115
|
+
configure: (
|
|
116
|
+
builder: Parameters<NonNullable<Parameters<typeof bootModule>[0]["configure"]>>[0],
|
|
117
|
+
) => unknown = () => undefined,
|
|
118
|
+
) {
|
|
119
|
+
return bootModule({
|
|
120
|
+
controllers: [ApiController],
|
|
121
|
+
configure: (builder) => {
|
|
122
|
+
builder.withStatic("/ui/", ui);
|
|
123
|
+
return configure(builder);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A request with the path exactly as written; `fetch` would tidy `..` away before it left. */
|
|
129
|
+
function raw(
|
|
130
|
+
baseUrl: string,
|
|
131
|
+
rawPath: string,
|
|
132
|
+
init: { method?: string; headers?: Record<string, string> } = {},
|
|
133
|
+
) {
|
|
134
|
+
const url = new URL(baseUrl);
|
|
135
|
+
return new Promise<{
|
|
136
|
+
status: number;
|
|
137
|
+
headers: Record<string, string | string[] | undefined>;
|
|
138
|
+
body: string;
|
|
139
|
+
}>((resolve, reject) => {
|
|
140
|
+
const req = request(
|
|
141
|
+
{
|
|
142
|
+
host: url.hostname,
|
|
143
|
+
port: url.port,
|
|
144
|
+
path: rawPath,
|
|
145
|
+
method: init.method ?? "GET",
|
|
146
|
+
headers: init.headers,
|
|
147
|
+
},
|
|
148
|
+
(res) => {
|
|
149
|
+
let body = "";
|
|
150
|
+
res.setEncoding("utf8");
|
|
151
|
+
res.on("data", (chunk: string) => {
|
|
152
|
+
body += chunk;
|
|
153
|
+
});
|
|
154
|
+
res.on("end", () =>
|
|
155
|
+
resolve({ status: res.statusCode ?? 0, headers: res.headers, body }),
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
req.on("error", reject);
|
|
160
|
+
req.end();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("serving a built UI from the API (#638)", () => {
|
|
165
|
+
describe("caching", () => {
|
|
166
|
+
it("caches a hashed asset for a year, as immutable", async () => {
|
|
167
|
+
await using api = await boot();
|
|
168
|
+
|
|
169
|
+
for (const file of ["assets/index-DiwrgTda.js", "assets/style.3f9a8c1b.css"]) {
|
|
170
|
+
const response = await fetch(`${api.baseUrl}/ui/${file}`);
|
|
171
|
+
expect(response.status).toBe(200);
|
|
172
|
+
expect(response.headers.get("cache-control")).toBe(
|
|
173
|
+
"public, max-age=31536000, immutable",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("serves index.html at the prefix with no-cache", async () => {
|
|
179
|
+
await using api = await boot();
|
|
180
|
+
|
|
181
|
+
const response = await fetch(`${api.baseUrl}/ui/`);
|
|
182
|
+
|
|
183
|
+
expect(response.status).toBe(200);
|
|
184
|
+
expect(response.headers.get("content-type")).toContain("text/html");
|
|
185
|
+
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
186
|
+
expect(await response.text()).toContain("the app");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("serves the module-federation remote entry with no-cache", async () => {
|
|
190
|
+
await using api = await boot();
|
|
191
|
+
|
|
192
|
+
const response = await fetch(`${api.baseUrl}/ui/remoteEntry.js`);
|
|
193
|
+
|
|
194
|
+
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("revalidates every other file, including a name that is not a hash", async () => {
|
|
198
|
+
await using api = await boot();
|
|
199
|
+
|
|
200
|
+
for (const file of ["favicon.ico", "assets/logo-background.png"]) {
|
|
201
|
+
const response = await fetch(`${api.baseUrl}/ui/${file}`);
|
|
202
|
+
expect(response.headers.get("cache-control")).toBe(
|
|
203
|
+
"public, max-age=0, must-revalidate",
|
|
204
|
+
);
|
|
205
|
+
expect(response.headers.get("etag")).toBeTruthy();
|
|
206
|
+
expect(response.headers.get("last-modified")).toBeTruthy();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("answers a conditional request with 304 and no body", async () => {
|
|
211
|
+
await using api = await boot();
|
|
212
|
+
const file = "/ui/assets/index-DiwrgTda.js";
|
|
213
|
+
const first = await raw(api.baseUrl, file);
|
|
214
|
+
const etag = first.headers.etag as string;
|
|
215
|
+
const modified = first.headers["last-modified"] as string;
|
|
216
|
+
|
|
217
|
+
const byEtag = await raw(api.baseUrl, file, { headers: { "if-none-match": etag } });
|
|
218
|
+
const byDate = await raw(api.baseUrl, file, {
|
|
219
|
+
headers: { "if-modified-since": modified },
|
|
220
|
+
});
|
|
221
|
+
const changed = await raw(api.baseUrl, file, {
|
|
222
|
+
headers: { "if-none-match": '"other"' },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(byEtag.status).toBe(304);
|
|
226
|
+
expect(byEtag.body).toBe("");
|
|
227
|
+
expect(byEtag.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
228
|
+
expect(byDate.status).toBe(304);
|
|
229
|
+
expect(changed.status).toBe(200);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("answers HEAD with the headers of the GET and no body", async () => {
|
|
233
|
+
await using api = await boot();
|
|
234
|
+
|
|
235
|
+
const response = await raw(api.baseUrl, "/ui/assets/index-DiwrgTda.js", {
|
|
236
|
+
method: "HEAD",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(response.status).toBe(200);
|
|
240
|
+
expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
241
|
+
expect(response.headers["content-length"]).toBe(String("console.log('app')".length));
|
|
242
|
+
expect(response.body).toBe("");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("lets the options replace the rules", async () => {
|
|
246
|
+
await using api = await bootModule({
|
|
247
|
+
controllers: [ApiController],
|
|
248
|
+
configure: (builder) =>
|
|
249
|
+
builder.withStatic("/ui/", ui, {
|
|
250
|
+
noCache: ["index.html", "favicon.ico"],
|
|
251
|
+
immutable: /^assets\//,
|
|
252
|
+
cacheControl: "public, max-age=60",
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const cache = async (file: string) =>
|
|
257
|
+
(await fetch(`${api.baseUrl}/ui/${file}`)).headers.get("cache-control");
|
|
258
|
+
|
|
259
|
+
expect(await cache("favicon.ico")).toBe("no-cache");
|
|
260
|
+
// No longer in the list.
|
|
261
|
+
expect(await cache("remoteEntry.js")).toBe("public, max-age=60");
|
|
262
|
+
expect(await cache("assets/logo-background.png")).toBe(
|
|
263
|
+
"public, max-age=31536000, immutable",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("single-page app fallback", () => {
|
|
269
|
+
it("serves index.html for a client route, so a reload works", async () => {
|
|
270
|
+
await using api = await boot();
|
|
271
|
+
|
|
272
|
+
for (const route of [
|
|
273
|
+
"/ui/portfolio",
|
|
274
|
+
"/ui/portfolio/42/edit",
|
|
275
|
+
"/ui/portfolio/42/?tab=pnl",
|
|
276
|
+
]) {
|
|
277
|
+
const response = await fetch(`${api.baseUrl}${route}`, { headers: browser });
|
|
278
|
+
expect(response.status, route).toBe(200);
|
|
279
|
+
expect(response.headers.get("content-type")).toContain("text/html");
|
|
280
|
+
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
281
|
+
expect(await response.text()).toContain("the app");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("serves it to HEAD too", async () => {
|
|
286
|
+
await using api = await boot();
|
|
287
|
+
|
|
288
|
+
const response = await raw(api.baseUrl, "/ui/portfolio", {
|
|
289
|
+
method: "HEAD",
|
|
290
|
+
headers: browser,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(response.status).toBe(200);
|
|
294
|
+
expect(response.headers["content-type"]).toContain("text/html");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps a missing file a 404, not the app", async () => {
|
|
298
|
+
await using api = await boot();
|
|
299
|
+
|
|
300
|
+
for (const missing of [
|
|
301
|
+
"/ui/assets/gone-ABCdef12.js",
|
|
302
|
+
"/ui/nope.css",
|
|
303
|
+
"/ui/deep/dir/x.png",
|
|
304
|
+
]) {
|
|
305
|
+
const response = await fetch(`${api.baseUrl}${missing}`);
|
|
306
|
+
expect(response.status, missing).toBe(404);
|
|
307
|
+
expect(await response.text()).not.toContain("the app");
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("does not fall back for a method other than GET or HEAD", async () => {
|
|
312
|
+
await using api = await boot();
|
|
313
|
+
|
|
314
|
+
const response = await fetch(`${api.baseUrl}/ui/portfolio`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: browser,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(response.status).toBe(404);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("can be turned off", async () => {
|
|
323
|
+
await using api = await bootModule({
|
|
324
|
+
controllers: [ApiController],
|
|
325
|
+
configure: (builder) => builder.withStatic("/ui/", ui, { spaFallback: false }),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect((await fetch(`${api.baseUrl}/ui/portfolio`, { headers: browser })).status).toBe(
|
|
329
|
+
404,
|
|
330
|
+
);
|
|
331
|
+
expect((await fetch(`${api.baseUrl}/ui/`)).status).toBe(200);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("never answers an API 404 with the app", async () => {
|
|
335
|
+
await using api = await boot();
|
|
336
|
+
|
|
337
|
+
const unknownApi = await fetch(`${api.baseUrl}/api/unknown`, { headers: browser });
|
|
338
|
+
const unknownRoot = await fetch(`${api.baseUrl}/unknown`, { headers: browser });
|
|
339
|
+
const lookalike = await fetch(`${api.baseUrl}/uix/page`, { headers: browser });
|
|
340
|
+
|
|
341
|
+
for (const response of [unknownApi, unknownRoot, lookalike]) {
|
|
342
|
+
expect(response.status).toBe(404);
|
|
343
|
+
expect(await response.text()).not.toContain("the app");
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("redirects the prefix without its trailing slash to the one with", async () => {
|
|
348
|
+
await using api = await boot();
|
|
349
|
+
|
|
350
|
+
const response = await fetch(`${api.baseUrl}/ui?x=1`, { redirect: "manual" });
|
|
351
|
+
|
|
352
|
+
expect(response.status).toBe(301);
|
|
353
|
+
expect(response.headers.get("location")).toBe("/ui/?x=1");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("has no fallback to serve when the directory has no index.html", async () => {
|
|
357
|
+
const bare = path.join(site, "bare");
|
|
358
|
+
await mkdir(bare, { recursive: true });
|
|
359
|
+
await writeFile(path.join(bare, "app.js"), "x");
|
|
360
|
+
await using api = await bootModule({
|
|
361
|
+
controllers: [ApiController],
|
|
362
|
+
configure: (builder) => builder.withStatic("/ui/", bare),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect((await fetch(`${api.baseUrl}/ui/route`, { headers: browser })).status).toBe(404);
|
|
366
|
+
expect((await fetch(`${api.baseUrl}/ui/app.js`)).status).toBe(200);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("outside the HTTP pipeline", () => {
|
|
371
|
+
const secured = () =>
|
|
372
|
+
bootModule({
|
|
373
|
+
controllers: [ApiController, SecuredController],
|
|
374
|
+
services: (c) =>
|
|
375
|
+
c.registerSingleton(ConfigurationServiceToken, new SecretConfigService()),
|
|
376
|
+
configure: (builder) => builder.withAuth().withStatic("/ui/", ui),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("serves the UI to an anonymous caller while an API route still says 401", async () => {
|
|
380
|
+
await using api = await secured();
|
|
381
|
+
|
|
382
|
+
expect((await fetch(`${api.baseUrl}/api/articles`)).status).toBe(401);
|
|
383
|
+
expect((await fetch(`${api.baseUrl}/ui/`)).status).toBe(200);
|
|
384
|
+
expect((await fetch(`${api.baseUrl}/ui/assets/index-DiwrgTda.js`)).status).toBe(200);
|
|
385
|
+
expect(
|
|
386
|
+
(await fetch(`${api.baseUrl}/ui/some/client/route`, { headers: browser })).status,
|
|
387
|
+
).toBe(200);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("ignores a bad token on the UI as well", async () => {
|
|
391
|
+
await using api = await secured();
|
|
392
|
+
|
|
393
|
+
const response = await fetch(`${api.baseUrl}/ui/`, {
|
|
394
|
+
headers: { authorization: "Bearer not-a-token" },
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(response.status).toBe(200);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("still serves the API to a caller with a token", async () => {
|
|
401
|
+
await using api = await secured();
|
|
402
|
+
const token = await new TokenService(secretConfig).sign({
|
|
403
|
+
sub: "alice",
|
|
404
|
+
scope: "read:Article",
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const response = await fetch(`${api.baseUrl}/api/articles`, {
|
|
408
|
+
headers: { authorization: `Bearer ${token}` },
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(response.status).toBe(200);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("is not counted by the rate limit, while the API is", async () => {
|
|
415
|
+
await using api = await bootModule({
|
|
416
|
+
controllers: [ApiController],
|
|
417
|
+
configure: (builder) => builder.withRateLimit({ max: 3 }).withStatic("/ui/", ui),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const ui1 = [];
|
|
421
|
+
for (let i = 0; i < 10; i += 1) {
|
|
422
|
+
ui1.push((await fetch(`${api.baseUrl}/ui/assets/index-DiwrgTda.js`)).status);
|
|
423
|
+
ui1.push((await fetch(`${api.baseUrl}/ui/some/route`, { headers: browser })).status);
|
|
424
|
+
}
|
|
425
|
+
const apiStatuses = [];
|
|
426
|
+
for (let i = 0; i < 5; i += 1) {
|
|
427
|
+
apiStatuses.push((await fetch(`${api.baseUrl}/api/ping`)).status);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
expect(new Set(ui1)).toEqual(new Set([200]));
|
|
431
|
+
expect(apiStatuses).toEqual([200, 200, 200, 429, 429]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("keeps a caller's own rate-limit allow list working", async () => {
|
|
435
|
+
await using api = await bootModule({
|
|
436
|
+
controllers: [ApiController],
|
|
437
|
+
configure: (builder) =>
|
|
438
|
+
builder.withRateLimit({ max: 1, allowList: ["127.0.0.1"] }).withStatic("/ui/", ui),
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const statuses = [];
|
|
442
|
+
for (let i = 0; i < 3; i += 1) {
|
|
443
|
+
statuses.push((await fetch(`${api.baseUrl}/api/ping`)).status);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
expect(statuses).toEqual([200, 200, 200]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("is not listed in the OpenAPI document", async () => {
|
|
450
|
+
await using api = await bootModule({
|
|
451
|
+
controllers: [ApiController],
|
|
452
|
+
configure: (builder) =>
|
|
453
|
+
builder.withOpenApi({ info: { title: "t", version: "1" } }).withStatic("/ui/", ui),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const document = (await (
|
|
457
|
+
await fetch(`${api.baseUrl}/openapi.json`)
|
|
458
|
+
).json()) as OpenApiDocument;
|
|
459
|
+
|
|
460
|
+
expect(Object.keys(document.paths)).toEqual(["/api/ping"]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("still sends the security headers", async () => {
|
|
464
|
+
await using api = await boot();
|
|
465
|
+
|
|
466
|
+
const response = await fetch(`${api.baseUrl}/ui/`);
|
|
467
|
+
|
|
468
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("security", () => {
|
|
473
|
+
it("never leaves the directory, however the dots are written", async () => {
|
|
474
|
+
await using api = await boot();
|
|
475
|
+
const attempts = [
|
|
476
|
+
"/ui/../secret.txt",
|
|
477
|
+
"/ui/..%2fsecret.txt",
|
|
478
|
+
"/ui/%2e%2e/secret.txt",
|
|
479
|
+
"/ui/%2e%2e%2fsecret.txt",
|
|
480
|
+
"/ui/%2E%2E%2Fsecret.txt",
|
|
481
|
+
"/ui/..%5csecret.txt",
|
|
482
|
+
"/ui/assets/../../secret.txt",
|
|
483
|
+
"/ui/assets/%2e%2e/%2e%2e/secret.txt",
|
|
484
|
+
"/ui/....//secret.txt",
|
|
485
|
+
"/ui/%252e%252e%252fsecret.txt",
|
|
486
|
+
"/ui/..%00/secret.txt",
|
|
487
|
+
"/ui//../secret.txt",
|
|
488
|
+
"/ui/../ui-sibling.txt",
|
|
489
|
+
"/ui/%2e%2e/ui-sibling.txt",
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
for (const attempt of attempts) {
|
|
493
|
+
const response = await raw(api.baseUrl, attempt);
|
|
494
|
+
expect(response.body, attempt).not.toContain("outside the root");
|
|
495
|
+
expect(response.body, attempt).not.toContain("sibling");
|
|
496
|
+
expect([400, 403, 404], attempt).toContain(response.status);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("does not serve dotfiles or anything in a dot folder", async () => {
|
|
501
|
+
await using api = await boot();
|
|
502
|
+
|
|
503
|
+
for (const hidden of ["/ui/.env", "/ui/.hidden/key.txt", "/ui/%2eenv"]) {
|
|
504
|
+
const response = await fetch(`${api.baseUrl}${hidden}`);
|
|
505
|
+
expect(response.status, hidden).toBe(404);
|
|
506
|
+
const body = await response.text();
|
|
507
|
+
expect(body).not.toContain("SECRET");
|
|
508
|
+
expect(body).not.toContain("the app");
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("does not list a directory", async () => {
|
|
513
|
+
await using api = await boot();
|
|
514
|
+
|
|
515
|
+
for (const directory of ["/ui/assets/", "/ui/assets", "/ui/empty-dir/"]) {
|
|
516
|
+
const response = await fetch(`${api.baseUrl}${directory}`);
|
|
517
|
+
const body = await response.text();
|
|
518
|
+
expect(body, directory).not.toContain("index-DiwrgTda.js");
|
|
519
|
+
expect(body, directory).not.toContain("logo-background.png");
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe("where the directory is", () => {
|
|
525
|
+
it("is resolved from the process working directory", async () => {
|
|
526
|
+
vi.spyOn(process, "cwd").mockReturnValue(site);
|
|
527
|
+
await using api = await bootModule({
|
|
528
|
+
controllers: [ApiController],
|
|
529
|
+
configure: (builder) => builder.withStatic("/ui/", "ui"),
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const response = await fetch(`${api.baseUrl}/ui/`);
|
|
533
|
+
|
|
534
|
+
expect(response.status).toBe(200);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("stops the app at start when it is missing, outside development", async () => {
|
|
538
|
+
vi.stubEnv("NODE_ENV", "production");
|
|
539
|
+
|
|
540
|
+
const boot = bootModule({
|
|
541
|
+
controllers: [ApiController],
|
|
542
|
+
configure: (builder) => builder.withStatic("/ui/", "no-such-ui-build"),
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await expect(boot).rejects.toThrow(
|
|
546
|
+
/no-such-ui-build.*does not exist.*Build the UI first/s,
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("lets the API boot with a warning in development, and the prefix answers 404", async () => {
|
|
551
|
+
vi.stubEnv("NODE_ENV", "development");
|
|
552
|
+
await using api = await bootModule({
|
|
553
|
+
controllers: [ApiController],
|
|
554
|
+
configure: (builder) => builder.withStatic("/ui/", "no-such-ui-build"),
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect((await fetch(`${api.baseUrl}/api/ping`)).status).toBe(200);
|
|
558
|
+
expect((await fetch(`${api.baseUrl}/ui/`)).status).toBe(404);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("follows onMissingDirectory over the environment", async () => {
|
|
562
|
+
vi.stubEnv("NODE_ENV", "production");
|
|
563
|
+
await using api = await bootModule({
|
|
564
|
+
controllers: [ApiController],
|
|
565
|
+
configure: (builder) =>
|
|
566
|
+
builder.withStatic("/ui/", "no-such-ui-build", { onMissingDirectory: "warn" }),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect((await fetch(`${api.baseUrl}/api/ping`)).status).toBe(200);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("refuses an empty prefix", () => {
|
|
573
|
+
const mount = bootModule({
|
|
574
|
+
controllers: [ApiController],
|
|
575
|
+
configure: (builder) => builder.withStatic("", ui),
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
return expect(mount).rejects.toThrow(/needs a prefix/);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("can serve more than one UI", async () => {
|
|
582
|
+
await using api = await bootModule({
|
|
583
|
+
controllers: [ApiController],
|
|
584
|
+
configure: (builder) => builder.withStatic("/ui/", ui).withStatic("/admin/", ui),
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
expect((await fetch(`${api.baseUrl}/ui/`)).status).toBe(200);
|
|
588
|
+
expect(
|
|
589
|
+
(await fetch(`${api.baseUrl}/admin/some/route`, { headers: browser })).status,
|
|
590
|
+
).toBe(200);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe("served from the root (#644)", () => {
|
|
595
|
+
const bootRoot = (options?: StaticOptions) =>
|
|
596
|
+
bootModule({
|
|
597
|
+
controllers: [ApiController, PortfolioController, SecuredController],
|
|
598
|
+
services: (c) =>
|
|
599
|
+
c.registerSingleton(ConfigurationServiceToken, new SecretConfigService()),
|
|
600
|
+
configure: (builder) =>
|
|
601
|
+
builder
|
|
602
|
+
.withAuth()
|
|
603
|
+
.withHealth()
|
|
604
|
+
.withOpenApi({ info: { title: "t", version: "1" } })
|
|
605
|
+
.withStatic("/", ui, options),
|
|
606
|
+
});
|
|
607
|
+
const json = { accept: "application/json" };
|
|
608
|
+
const notFound = async (response: Response) => {
|
|
609
|
+
expect(response.status).toBe(404);
|
|
610
|
+
expect(response.headers.get("content-type")).toContain("application/json");
|
|
611
|
+
const body = (await response.json()) as {
|
|
612
|
+
message: string;
|
|
613
|
+
error: string;
|
|
614
|
+
statusCode: number;
|
|
615
|
+
};
|
|
616
|
+
expect(body.error).toBe("Not Found");
|
|
617
|
+
expect(body.statusCode).toBe(404);
|
|
618
|
+
return body;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
it("serves GET /, HEAD / and an asset from the root", async () => {
|
|
622
|
+
await using api = await bootRoot();
|
|
623
|
+
|
|
624
|
+
const index = await fetch(`${api.baseUrl}/`);
|
|
625
|
+
const head = await raw(api.baseUrl, "/", { method: "HEAD" });
|
|
626
|
+
const asset = await fetch(`${api.baseUrl}/assets/index-DiwrgTda.js`);
|
|
627
|
+
|
|
628
|
+
expect(index.status).toBe(200);
|
|
629
|
+
expect(await index.text()).toContain("the app");
|
|
630
|
+
expect(head.status).toBe(200);
|
|
631
|
+
expect(head.headers["content-type"]).toContain("text/html");
|
|
632
|
+
expect(head.body).toBe("");
|
|
633
|
+
expect(asset.status).toBe(200);
|
|
634
|
+
expect(await asset.text()).toBe("console.log('app')");
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("serves index.html to a browser navigation to an unknown client route", async () => {
|
|
638
|
+
await using api = await bootRoot();
|
|
639
|
+
|
|
640
|
+
for (const route of ["/some/client/route", "/portfolio/42?tab=pnl", "/lpx"]) {
|
|
641
|
+
const response = await fetch(`${api.baseUrl}${route}`, { headers: browser });
|
|
642
|
+
expect(response.status, route).toBe(200);
|
|
643
|
+
expect(response.headers.get("content-type")).toContain("text/html");
|
|
644
|
+
expect(await response.text()).toContain("the app");
|
|
645
|
+
}
|
|
646
|
+
const head = await raw(api.baseUrl, "/some/route", {
|
|
647
|
+
method: "HEAD",
|
|
648
|
+
headers: browser,
|
|
649
|
+
});
|
|
650
|
+
expect(head.status).toBe(200);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("gives the JSON 404 to a request that does not ask for html", async () => {
|
|
654
|
+
await using api = await bootRoot();
|
|
655
|
+
|
|
656
|
+
for (const headers of [json, { accept: "*/*" }, {}, { accept: "text/*" }]) {
|
|
657
|
+
const response = await fetch(`${api.baseUrl}/some/client/route`, { headers });
|
|
658
|
+
const body = await notFound(response);
|
|
659
|
+
expect(body.message).toBe("Route GET:/some/client/route not found");
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("answers the same 404 as an app with no UI", async () => {
|
|
664
|
+
await using plain = await bootModule({ controllers: [ApiController] });
|
|
665
|
+
await using api = await bootRoot();
|
|
666
|
+
const route = "/some/client/route";
|
|
667
|
+
|
|
668
|
+
const without = await fetch(`${plain.baseUrl}${route}`, { headers: json });
|
|
669
|
+
const withRoot = await fetch(`${api.baseUrl}${route}`, { headers: json });
|
|
670
|
+
|
|
671
|
+
expect(withRoot.status).toBe(without.status);
|
|
672
|
+
expect(await withRoot.text()).toBe(await without.text());
|
|
673
|
+
expect(withRoot.headers.get("content-type")).toBe(without.headers.get("content-type"));
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("never answers a path under a controller route with the app, even for a browser", async () => {
|
|
677
|
+
await using api = await bootRoot();
|
|
678
|
+
|
|
679
|
+
for (const route of ["/lp/portfolo", "/lp", "/lp/a/b", "/api/unknown", "/health/x"]) {
|
|
680
|
+
await notFound(await fetch(`${api.baseUrl}${route}`, { headers: browser }));
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("keeps /health, a secured route, a plain route and OpenAPI working", async () => {
|
|
685
|
+
await using api = await bootRoot();
|
|
686
|
+
|
|
687
|
+
expect((await fetch(`${api.baseUrl}/health`, { headers: browser })).status).toBe(200);
|
|
688
|
+
expect((await fetch(`${api.baseUrl}/lp/portfolio`, { headers: browser })).status).toBe(
|
|
689
|
+
200,
|
|
690
|
+
);
|
|
691
|
+
expect((await fetch(`${api.baseUrl}/api/articles`, { headers: browser })).status).toBe(
|
|
692
|
+
401,
|
|
693
|
+
);
|
|
694
|
+
const spec = await fetch(`${api.baseUrl}/openapi.json`, { headers: browser });
|
|
695
|
+
expect(spec.status).toBe(200);
|
|
696
|
+
expect(spec.headers.get("content-type")).toContain("application/json");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("gives a missing asset and a dotfile a 404, not the app", async () => {
|
|
700
|
+
await using api = await bootRoot();
|
|
701
|
+
|
|
702
|
+
for (const missing of [
|
|
703
|
+
"/assets/gone-ABCdef12.js",
|
|
704
|
+
"/nope.css",
|
|
705
|
+
"/.env",
|
|
706
|
+
"/.hidden/key.txt",
|
|
707
|
+
]) {
|
|
708
|
+
const response = await fetch(`${api.baseUrl}${missing}`, { headers: browser });
|
|
709
|
+
expect(response.status, missing).toBe(404);
|
|
710
|
+
expect(await response.text()).not.toContain("the app");
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("gives POST to an unknown path the JSON 404", async () => {
|
|
715
|
+
await using api = await bootRoot();
|
|
716
|
+
|
|
717
|
+
const response = await fetch(`${api.baseUrl}/some/route`, {
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers: browser,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const body = await notFound(response);
|
|
723
|
+
expect(body.message).toBe("Route POST:/some/route not found");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("never answers an excluded path, as a file or as the app", async () => {
|
|
727
|
+
await using api = await bootRoot({ exclude: ["/private/**", "/assets/*.png"] });
|
|
728
|
+
|
|
729
|
+
await notFound(await fetch(`${api.baseUrl}/private/page`, { headers: browser }));
|
|
730
|
+
await notFound(await fetch(`${api.baseUrl}/private`, { headers: browser }));
|
|
731
|
+
await notFound(await fetch(`${api.baseUrl}/assets/logo-background.png`));
|
|
732
|
+
await notFound(await fetch(`${api.baseUrl}/%70rivate/page`, { headers: browser }));
|
|
733
|
+
expect((await fetch(`${api.baseUrl}/public/page`, { headers: browser })).status).toBe(
|
|
734
|
+
200,
|
|
735
|
+
);
|
|
736
|
+
expect((await fetch(`${api.baseUrl}/assets/index-DiwrgTda.js`)).status).toBe(200);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("sets the cache headers as under a prefix", async () => {
|
|
740
|
+
await using api = await bootRoot();
|
|
741
|
+
|
|
742
|
+
const cache = async (file: string, init?: RequestInit) =>
|
|
743
|
+
(await fetch(`${api.baseUrl}${file}`, init)).headers.get("cache-control");
|
|
744
|
+
|
|
745
|
+
expect(await cache("/")).toBe("no-cache");
|
|
746
|
+
expect(await cache("/some/route", { headers: browser })).toBe("no-cache");
|
|
747
|
+
expect(await cache("/remoteEntry.js")).toBe("no-cache");
|
|
748
|
+
expect(await cache("/assets/index-DiwrgTda.js")).toBe(
|
|
749
|
+
"public, max-age=31536000, immutable",
|
|
750
|
+
);
|
|
751
|
+
expect(await cache("/favicon.ico")).toBe("public, max-age=0, must-revalidate");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("exempts only the static route from the rate limit", async () => {
|
|
755
|
+
await using api = await bootModule({
|
|
756
|
+
controllers: [ApiController, PortfolioController],
|
|
757
|
+
configure: (builder) => builder.withRateLimit({ max: 2 }).withStatic("/", ui),
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const files = [];
|
|
761
|
+
for (let i = 0; i < 8; i += 1) {
|
|
762
|
+
files.push((await fetch(`${api.baseUrl}/assets/index-DiwrgTda.js`)).status);
|
|
763
|
+
files.push((await fetch(`${api.baseUrl}/some/route`, { headers: browser })).status);
|
|
764
|
+
}
|
|
765
|
+
const apiStatuses = [];
|
|
766
|
+
for (let i = 0; i < 4; i += 1) {
|
|
767
|
+
apiStatuses.push((await fetch(`${api.baseUrl}/api/ping`)).status);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
expect(new Set(files)).toEqual(new Set([200]));
|
|
771
|
+
expect(apiStatuses).toEqual([200, 200, 429, 429]);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("blocks path traversal", async () => {
|
|
775
|
+
await using api = await bootRoot();
|
|
776
|
+
const attempts = [
|
|
777
|
+
"/../secret.txt",
|
|
778
|
+
"/..%2fsecret.txt",
|
|
779
|
+
"/%2e%2e/secret.txt",
|
|
780
|
+
"/%2e%2e%2f%2e%2e/secret.txt",
|
|
781
|
+
"/assets/../../secret.txt",
|
|
782
|
+
"/%252e%252e%252fsecret.txt",
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
for (const attempt of attempts) {
|
|
786
|
+
const response = await raw(api.baseUrl, attempt, { headers: browser });
|
|
787
|
+
expect(response.body, attempt).not.toContain("outside the root");
|
|
788
|
+
expect([200, 400, 403, 404], attempt).toContain(response.status);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("lets the more specific of two mounts win", async () => {
|
|
793
|
+
const admin = path.join(site, "admin-ui");
|
|
794
|
+
await mkdir(admin, { recursive: true });
|
|
795
|
+
await writeFile(path.join(admin, "index.html"), "<title>the admin</title>");
|
|
796
|
+
await using api = await bootModule({
|
|
797
|
+
controllers: [ApiController],
|
|
798
|
+
configure: (builder) => builder.withStatic("/", ui).withStatic("/admin/", admin),
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
const text = async (route: string) =>
|
|
802
|
+
(await fetch(`${api.baseUrl}${route}`, { headers: browser })).text();
|
|
803
|
+
|
|
804
|
+
expect(await text("/")).toContain("the app");
|
|
805
|
+
expect(await text("/some/route")).toContain("the app");
|
|
806
|
+
expect(await text("/admin/")).toContain("the admin");
|
|
807
|
+
expect(await text("/admin/users/3")).toContain("the admin");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("still redirects a non-root prefix without its slash, next to a root mount", async () => {
|
|
811
|
+
const admin = path.join(site, "admin-ui");
|
|
812
|
+
await mkdir(admin, { recursive: true });
|
|
813
|
+
await writeFile(path.join(admin, "index.html"), "<title>the admin</title>");
|
|
814
|
+
await using api = await bootModule({
|
|
815
|
+
controllers: [ApiController],
|
|
816
|
+
configure: (builder) => builder.withStatic("/", ui).withStatic("/admin/", admin),
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const response = await fetch(`${api.baseUrl}/admin?x=1`, { redirect: "manual" });
|
|
820
|
+
|
|
821
|
+
expect(response.status).toBe(301);
|
|
822
|
+
expect(response.headers.get("location")).toBe("/admin/?x=1");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("can switch the fallback off, keeping the files", async () => {
|
|
826
|
+
await using api = await bootRoot({ spaFallback: false });
|
|
827
|
+
|
|
828
|
+
expect((await fetch(`${api.baseUrl}/`)).status).toBe(200);
|
|
829
|
+
await notFound(await fetch(`${api.baseUrl}/some/route`, { headers: browser }));
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
describe("the fallback under a prefix decides by what is asking (#644)", () => {
|
|
834
|
+
it("gives a request that does not ask for html the JSON 404", async () => {
|
|
835
|
+
await using api = await boot();
|
|
836
|
+
|
|
837
|
+
const asJson = await fetch(`${api.baseUrl}/ui/portfolio`, {
|
|
838
|
+
headers: { accept: "application/json" },
|
|
839
|
+
});
|
|
840
|
+
const asAny = await fetch(`${api.baseUrl}/ui/portfolio`);
|
|
841
|
+
|
|
842
|
+
expect(asJson.status).toBe(404);
|
|
843
|
+
expect(asAny.status).toBe(404);
|
|
844
|
+
expect(await asAny.text()).not.toContain("the app");
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
});
|