@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2
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.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -15,22 +15,23 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* These pin the parts of the scaffold that are checkable from inside the
|
|
18
|
+
* Pins the parts of the scaffold that are checkable from inside the
|
|
21
19
|
* repository. The scaffold is the artifact an operator actually deploys, and
|
|
22
|
-
*
|
|
20
|
+
* a mistake in it stays invisible until someone reads the file: the container
|
|
23
21
|
* install losing an allowlist, the dev compose dialling a Redis that is not
|
|
24
|
-
* there,
|
|
22
|
+
* there, one `git add .` committing the signing key.
|
|
25
23
|
*/
|
|
26
24
|
|
|
27
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
26
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import {
|
|
27
|
+
import { BootError } from "@o3co/auth-provider-core";
|
|
30
28
|
import { parseFile } from "@o3co/ts.hocon";
|
|
31
|
-
import { validate } from "@o3co/ts.hocon/zod";
|
|
32
29
|
import { describe, expect, it } from "vitest";
|
|
33
|
-
import {
|
|
30
|
+
import { readAdapters } from "../adapters.mjs";
|
|
31
|
+
import { buildModules } from "../buildModules.mjs";
|
|
32
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
|
|
33
|
+
import { templateReference } from "../modules.mjs";
|
|
34
|
+
import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
|
|
34
35
|
|
|
35
36
|
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
36
37
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
@@ -38,17 +39,11 @@ const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "ut
|
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* The `environment:` entries of a compose file's `app` service, as the
|
|
41
|
-
* environment the container would actually see.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* own scaffold would be a strange thing to ship to operators.
|
|
47
|
-
*
|
|
48
|
-
* Compose's `${VAR:?err}` required-variable form resolves from the operator's
|
|
49
|
-
* shell or `.env`, so there is no literal value to record — the key is mapped
|
|
50
|
-
* to `null`, which is the point of that form and what the assertions below
|
|
51
|
-
* check for.
|
|
42
|
+
* environment the container would actually see. Hand-rolled rather than a
|
|
43
|
+
* YAML dependency: both files are fixed-shape, the block is flat `KEY: value`
|
|
44
|
+
* scalars, and the template should not ship a parser to read its own
|
|
45
|
+
* scaffold. Compose's `${VAR:?err}` required-variable form resolves from the
|
|
46
|
+
* operator's shell or `.env`, so its key is mapped to `null`.
|
|
52
47
|
*/
|
|
53
48
|
function composeAppEnvironment(rel: string): Map<string, string | null> {
|
|
54
49
|
const lines = read(rel).split("\n");
|
|
@@ -67,51 +62,61 @@ function composeAppEnvironment(rel: string): Map<string, string | null> {
|
|
|
67
62
|
return env;
|
|
68
63
|
}
|
|
69
64
|
|
|
70
|
-
/**
|
|
71
|
-
function
|
|
65
|
+
/** The template's own layers under `env`, over its own reference, unparsed. */
|
|
66
|
+
function ownResolved(
|
|
67
|
+
env: Record<string, string>,
|
|
68
|
+
configEnv = "production",
|
|
69
|
+
): Record<string, unknown> {
|
|
72
70
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
73
|
-
return
|
|
74
|
-
parseFile(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
return parseFile(envConfPath, { env })
|
|
72
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
73
|
+
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
74
|
+
.toObject() as Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `session-store.storage.type` as the template's own layers resolve it under `env`. */
|
|
78
|
+
function sessionStorageTypeWith(env: Record<string, string>): unknown {
|
|
79
|
+
const section = ownResolved(env)["session-store"] as
|
|
80
|
+
| { readonly storage?: { readonly type?: unknown } }
|
|
81
|
+
| undefined;
|
|
82
|
+
return section?.storage?.type;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The adapters phase one reads under `env`. */
|
|
86
|
+
function adaptersWith(env: Record<string, string>, configEnv = "production"): Adapters {
|
|
87
|
+
return readAdapters(ownResolved(env, configEnv), env);
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
91
|
* A compose file's `environment:` block, plus the secrets no compose file
|
|
83
92
|
* carries (they come from `.env` or a compose secret) — the environment the
|
|
84
|
-
* process would boot under.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* algorithm alone; the dev compose supplies no key at all, so give it the
|
|
89
|
-
* HS256 shape, whose `.strict()` union member is exactly why the two cannot
|
|
90
|
-
* share one map.
|
|
93
|
+
* process would boot under. The production compose points the EdDSA key
|
|
94
|
+
* paths at its mounted secrets, so the default algorithm stays; the dev
|
|
95
|
+
* compose supplies no key, so it gets the HS256 shape, whose `.strict()`
|
|
96
|
+
* union member is why the two cannot share one map.
|
|
91
97
|
*/
|
|
92
98
|
function bootableEnv(rel: string): Record<string, string> {
|
|
93
99
|
const env: Record<string, string> = {
|
|
94
100
|
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
95
|
-
|
|
101
|
+
SESSION_STORE_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
|
|
96
102
|
};
|
|
97
103
|
for (const [key, value] of composeAppEnvironment(rel)) {
|
|
98
104
|
if (value !== null) env[key] = value;
|
|
99
105
|
}
|
|
100
|
-
if (env.
|
|
101
|
-
env.
|
|
102
|
-
env.
|
|
106
|
+
if (env.KEY_STORE_LOCAL_PRIVATE_KEY_PATH === undefined) {
|
|
107
|
+
env.KEY_STORE_LOCAL_ALGORITHM = "HS256";
|
|
108
|
+
env.KEY_STORE_LOCAL_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
|
|
103
109
|
}
|
|
104
110
|
return env;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
describe("
|
|
113
|
+
describe("the Dockerfile installs with everything pnpm needs", () => {
|
|
108
114
|
it("copies pnpm-workspace.yaml into the deps stage", () => {
|
|
109
115
|
// `create-auth-provider` generates it to carry the bcrypt
|
|
110
116
|
// `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
|
|
111
|
-
// setting ONLY from this file — in single-package projects too
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// is missing and bcrypt has to compile.
|
|
117
|
+
// setting ONLY from this file — in single-package projects too. A deps
|
|
118
|
+
// stage without it runs allowlist-less, which leaves bcrypt unbuilt the
|
|
119
|
+
// day an alpine prebuild is missing and it has to compile.
|
|
115
120
|
const dockerfile = read("/Dockerfile");
|
|
116
121
|
const depsStage = dockerfile.slice(
|
|
117
122
|
dockerfile.indexOf("FROM node-base AS deps"),
|
|
@@ -119,8 +124,7 @@ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
|
|
|
119
124
|
);
|
|
120
125
|
// Matched as a COPY instruction, not as a substring of the stage: the
|
|
121
126
|
// comment above that COPY names the file too, so a substring check
|
|
122
|
-
// would keep passing
|
|
123
|
-
// stayed — which is the failure this test exists to catch.
|
|
127
|
+
// would keep passing without the instruction.
|
|
124
128
|
const copyLines = depsStage
|
|
125
129
|
.split("\n")
|
|
126
130
|
.filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
|
|
@@ -128,13 +132,19 @@ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
|
|
|
128
132
|
});
|
|
129
133
|
});
|
|
130
134
|
|
|
131
|
-
describe("
|
|
135
|
+
describe("the dev compose can reach every Redis it configures", () => {
|
|
132
136
|
it("sets every *_REDIS_URL the template config reads", () => {
|
|
133
|
-
// The template's application.conf
|
|
134
|
-
// defaulting to `redis://localhost:6379` —
|
|
135
|
-
// the container itself. `.env.example` is
|
|
136
|
-
// so a URL missing from it is a boot that
|
|
137
|
-
|
|
137
|
+
// The template's application.conf and its own reference.conf substitute
|
|
138
|
+
// several Redis URLs, each defaulting to `redis://localhost:6379` —
|
|
139
|
+
// which inside the container is the container itself. `.env.example` is
|
|
140
|
+
// what the compose file loads, so a URL missing from it is a boot that
|
|
141
|
+
// dials nothing.
|
|
142
|
+
// The captures of renamed variables (`renamed-variables { … }`) read an
|
|
143
|
+
// old name only to refuse it, and dial nothing.
|
|
144
|
+
const conf = (read("/config/application.conf") + read("/config/reference.conf")).replace(
|
|
145
|
+
/^renamed-variables \{[\s\S]*?^\}/m,
|
|
146
|
+
"",
|
|
147
|
+
);
|
|
138
148
|
const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
|
|
139
149
|
expect(declared.length).toBeGreaterThan(0);
|
|
140
150
|
|
|
@@ -146,7 +156,7 @@ describe("#407 — the dev compose can reach every Redis it configures", () => {
|
|
|
146
156
|
});
|
|
147
157
|
});
|
|
148
158
|
|
|
149
|
-
describe("
|
|
159
|
+
describe("the scaffold does not invite committing its own secrets", () => {
|
|
150
160
|
it("ships a .gitignore", () => {
|
|
151
161
|
// Without one, the first `git add .` in a scaffolded project commits
|
|
152
162
|
// `.env` and `jwt-private.pem` — both of which the README tells the
|
|
@@ -168,7 +178,7 @@ describe("#407 — the scaffold does not invite committing its own secrets", ()
|
|
|
168
178
|
});
|
|
169
179
|
});
|
|
170
180
|
|
|
171
|
-
describe("
|
|
181
|
+
describe("the production compose matches the topology it documents", () => {
|
|
172
182
|
it("does not publish the app port on every interface", () => {
|
|
173
183
|
// The README says to keep `/metrics` off the public listener and the
|
|
174
184
|
// file assumes TLS is terminated in front, so a bare "3000:3000"
|
|
@@ -179,38 +189,193 @@ describe("#407 — the production compose matches the topology it documents", ()
|
|
|
179
189
|
});
|
|
180
190
|
});
|
|
181
191
|
|
|
192
|
+
/**
|
|
193
|
+
* What `.gitignore` keeps out of git (secrets and per-machine configuration,
|
|
194
|
+
* the client registry among them) stays out of the build context too: the
|
|
195
|
+
* `Dockerfile` copies `config/`, so a `config/clients.yaml` in the working
|
|
196
|
+
* copy, client secrets included, would be baked into the runtime image.
|
|
197
|
+
* Production is given the registry from outside the image instead.
|
|
198
|
+
*/
|
|
199
|
+
describe("what .gitignore keeps out of git stays out of the image, and production is given it", () => {
|
|
200
|
+
/**
|
|
201
|
+
* `.gitignore`'s "Secrets and per-machine configuration" section: each
|
|
202
|
+
* pattern, with files it matches. A pattern without a `/` matches at any
|
|
203
|
+
* depth in `.gitignore`, so those carry a nested file too — `.dockerignore`
|
|
204
|
+
* anchors every pattern at the context root, where `*` stops at a `/`.
|
|
205
|
+
*/
|
|
206
|
+
const PER_DEPLOYMENT: Readonly<Record<string, readonly string[]>> = {
|
|
207
|
+
".env": [".env", "config/.env"],
|
|
208
|
+
".env.*": [".env.local", ".env.production", "config/.env.production"],
|
|
209
|
+
"*.pem": ["jwt-private.pem", "config/jwt-private.pem"],
|
|
210
|
+
"*.key": ["tls.key", "config/tls.key"],
|
|
211
|
+
"config/clients.yaml": ["config/clients.yaml"],
|
|
212
|
+
"config/*.local.conf": ["config/production.local.conf"],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The committed files beside them, which the image does need. `.env.example`
|
|
217
|
+
* is what `.gitignore` re-includes, at any depth: the same here.
|
|
218
|
+
*/
|
|
219
|
+
const COMMITTED = [
|
|
220
|
+
".env.example",
|
|
221
|
+
"config/.env.example",
|
|
222
|
+
"config/application.conf",
|
|
223
|
+
"config/development.conf",
|
|
224
|
+
"config/production.conf",
|
|
225
|
+
"config/mailpit.conf",
|
|
226
|
+
"config/clients.yaml.example",
|
|
227
|
+
] as const;
|
|
228
|
+
|
|
229
|
+
it("checks every pattern of .gitignore's secrets section", () => {
|
|
230
|
+
const lines = read("/.gitignore").split("\n");
|
|
231
|
+
const start = lines.indexOf("# Secrets and per-machine configuration");
|
|
232
|
+
expect(start).not.toBe(-1);
|
|
233
|
+
const section: string[] = [];
|
|
234
|
+
for (const line of lines.slice(start + 1)) {
|
|
235
|
+
if (line.trim() === "") break;
|
|
236
|
+
if (!line.startsWith("#") && !line.startsWith("!")) section.push(line.trim());
|
|
237
|
+
}
|
|
238
|
+
expect(section).toEqual(Object.keys(PER_DEPLOYMENT));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it.each(Object.entries(PER_DEPLOYMENT))(
|
|
242
|
+
"keeps what %s matches out of the build context",
|
|
243
|
+
(_, files) => {
|
|
244
|
+
const dockerignore = read("/.dockerignore");
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
expect(dockerignoreExcludes(dockerignore, file), `${file} reaches the build context`).toBe(
|
|
247
|
+
true,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
it("lets the committed configuration through", () => {
|
|
254
|
+
const dockerignore = read("/.dockerignore");
|
|
255
|
+
for (const file of COMMITTED) {
|
|
256
|
+
expect(dockerignoreExcludes(dockerignore, file), `${file} is excluded`).toBe(false);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("gives the production compose's process the client registry, read-only, from outside the image", () => {
|
|
261
|
+
// A compose secret, like the key pair: mounted read-only under
|
|
262
|
+
// /run/secrets, never part of a layer.
|
|
263
|
+
const name = "client_registry";
|
|
264
|
+
expect(composeAppSecrets("/docker-compose.production.yml")).toContain(name);
|
|
265
|
+
expect(composeSecretFiles("/docker-compose.production.yml").get(name)).toBe(
|
|
266
|
+
"./config/clients.yaml",
|
|
267
|
+
);
|
|
268
|
+
// And the process reads it there, through the real config layers.
|
|
269
|
+
const env = bootableEnv("/docker-compose.production.yml");
|
|
270
|
+
expect(adaptersWith(env).clientRepository).toBe("yaml");
|
|
271
|
+
expect(repositoriesSectionSchema.parse(ownResolved(env).repositories).client.yaml.path).toBe(
|
|
272
|
+
`/run/secrets/${name}`,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/** The secrets a compose file's `app` service mounts. */
|
|
278
|
+
function composeAppSecrets(rel: string): string[] {
|
|
279
|
+
const lines = read(rel).split("\n");
|
|
280
|
+
const start = lines.findIndex((line) => /^\s{4}secrets:\s*$/.test(line));
|
|
281
|
+
const names: string[] = [];
|
|
282
|
+
if (start === -1) return names;
|
|
283
|
+
for (const line of lines.slice(start + 1)) {
|
|
284
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
285
|
+
const match = /^\s{6}-\s*(\S+)\s*$/.exec(line);
|
|
286
|
+
if (!match) break;
|
|
287
|
+
names.push(match[1] as string);
|
|
288
|
+
}
|
|
289
|
+
return names;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A compose file's top-level `secrets:`, as each name's `file:`. */
|
|
293
|
+
function composeSecretFiles(rel: string): Map<string, string> {
|
|
294
|
+
const lines = read(rel).split("\n");
|
|
295
|
+
const start = lines.findIndex((line) => /^secrets:\s*$/.test(line));
|
|
296
|
+
const files = new Map<string, string>();
|
|
297
|
+
if (start === -1) return files;
|
|
298
|
+
let name: string | undefined;
|
|
299
|
+
for (const line of lines.slice(start + 1)) {
|
|
300
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
301
|
+
if (!/^\s/.test(line)) break;
|
|
302
|
+
const entry = /^\s{2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
|
303
|
+
if (entry) {
|
|
304
|
+
name = entry[1];
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const file = /^\s{4}file:\s*(\S+)\s*$/.exec(line);
|
|
308
|
+
if (file && name !== undefined) files.set(name, file[1] as string);
|
|
309
|
+
}
|
|
310
|
+
return files;
|
|
311
|
+
}
|
|
312
|
+
|
|
182
313
|
describe("the compose files put a store and its lifetime-sibling on the same backend", () => {
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// `DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
|
|
314
|
+
// With express-session on Redis and the user-session stores on memory,
|
|
315
|
+
// express-session survives a restart and the `UserSession` it points at
|
|
316
|
+
// does not: every browser comes back `isAuthenticated` with nothing behind
|
|
317
|
+
// it and /authorize loops until the cookie is deleted by hand.
|
|
318
|
+
// `CORE_DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
|
|
190
319
|
// guard answers "can these stores be shared", not "do these two stores have
|
|
191
320
|
// the same lifetime" — so nothing but this assertion stands behind it.
|
|
192
321
|
for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
|
|
193
322
|
it(`${file} keeps the user-session stores with the express-session store`, () => {
|
|
194
|
-
const
|
|
323
|
+
const env = bootableEnv(file);
|
|
195
324
|
// Resolved through the real config layers, not read off the file:
|
|
196
325
|
// what matters is the value the process ends up with, whether the
|
|
197
326
|
// compose stated it or `config/application.conf` did.
|
|
198
|
-
expect(
|
|
199
|
-
expect(
|
|
327
|
+
expect(sessionStorageTypeWith(env)).toBe("redis");
|
|
328
|
+
expect(adaptersWith(env).userSessionStores).toBe("redis");
|
|
200
329
|
});
|
|
201
330
|
}
|
|
202
331
|
|
|
203
332
|
it("the production compose leaves no store on memory while a sibling is on Redis", () => {
|
|
204
|
-
const
|
|
333
|
+
const env = bootableEnv("/docker-compose.production.yml");
|
|
205
334
|
// Every store whose records must outlive one process. The federation
|
|
206
335
|
// token store is deliberately absent: this template ships every
|
|
207
336
|
// federation disabled, so nothing writes to it, and turning it on needs
|
|
208
337
|
// an AES key the compose file must not invent.
|
|
209
|
-
expect(
|
|
210
|
-
expect(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
338
|
+
expect(sessionStorageTypeWith(env)).toBe("redis");
|
|
339
|
+
expect(adaptersWith(env)).toMatchObject({
|
|
340
|
+
userSessionStores: "redis",
|
|
341
|
+
codeRepository: "redis",
|
|
342
|
+
accessTokenDenylist: "redis",
|
|
343
|
+
rateLimiter: "redis",
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/** `.env.example`'s uncommented lines that carry a value: what a `.env` copied from it sets. */
|
|
349
|
+
function dotenvExample(): Record<string, string> {
|
|
350
|
+
const env: Record<string, string> = {};
|
|
351
|
+
for (const line of read("/.env.example").split("\n")) {
|
|
352
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.+)$/.exec(line);
|
|
353
|
+
if (match) env[match[1] as string] = match[2] as string;
|
|
354
|
+
}
|
|
355
|
+
return env;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
describe("the production compose leaves MFA on, for the deployment to decide", () => {
|
|
359
|
+
it("reads the switch as required, so a deployment that sets nothing about MFA is refused naming MFA_MODE=off", () => {
|
|
360
|
+
// `environment:` wins over `env_file`, so the compose file's block is
|
|
361
|
+
// laid over the `.env` copied from `.env.example`.
|
|
362
|
+
const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
|
|
363
|
+
// `app.mts`: CONFIG_ENV, then NODE_ENV, then development.
|
|
364
|
+
const configEnv = env.CONFIG_ENV || env.NODE_ENV || "development";
|
|
365
|
+
expect(configEnv).toBe("production");
|
|
366
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
367
|
+
const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
|
|
368
|
+
expect(switches.mfaMode).toBe("required");
|
|
369
|
+
|
|
370
|
+
let err: unknown;
|
|
371
|
+
try {
|
|
372
|
+
buildModules(switches, { environment: configEnv });
|
|
373
|
+
} catch (caught) {
|
|
374
|
+
err = caught;
|
|
375
|
+
}
|
|
376
|
+
expect(err).toBeInstanceOf(BootError);
|
|
377
|
+
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
378
|
+
expect((err as BootError).message).toContain("MFA_MODE=off");
|
|
214
379
|
});
|
|
215
380
|
});
|
|
216
381
|
|
|
@@ -235,31 +400,25 @@ describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
|
|
|
235
400
|
});
|
|
236
401
|
});
|
|
237
402
|
|
|
238
|
-
describe("
|
|
403
|
+
describe("the READMEs' security advice", () => {
|
|
239
404
|
it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// still recommended it, so the two disagreed on which is safe.
|
|
405
|
+
// `trust proxy` is a CIDR/hop policy because `true` means "believe the
|
|
406
|
+
// leftmost forwarded entry from whoever opened the connection". The
|
|
407
|
+
// English README warns against it, and the Japanese one must agree.
|
|
244
408
|
expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
|
|
245
409
|
});
|
|
246
410
|
});
|
|
247
411
|
|
|
248
412
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* source outside `node_modules`, vitest inlines them, and the mocks apply —
|
|
257
|
-
* which is how replica-safety.test.mts stayed green here while dialling
|
|
258
|
-
* `redis.test` for real in every scaffold. The second half is `make test`:
|
|
259
|
-
* it runs this suite inside the `test` image, where a file the Dockerfile
|
|
260
|
-
* never copied simply does not exist.
|
|
413
|
+
* The suite that ships with a scaffolded project has to be green in that
|
|
414
|
+
* project, not only in this workspace. A scaffold installs
|
|
415
|
+
* `@o3co/auth-provider-*` from npm, under `node_modules`, where vitest
|
|
416
|
+
* externalizes them and their own `import "ioredis"` / `import "redis"` never
|
|
417
|
+
* meet the `vi.mock` registry unless inlined; here they are symlinks vitest
|
|
418
|
+
* inlines anyway. And `make test` runs this suite inside the `test` image,
|
|
419
|
+
* where a file the Dockerfile never copied does not exist.
|
|
261
420
|
*/
|
|
262
|
-
describe("
|
|
421
|
+
describe("the shipped suite carries what it needs to run outside this repository", () => {
|
|
263
422
|
it("runs the published packages through vitest, so module mocks reach them", async () => {
|
|
264
423
|
// The config is loaded, not grepped: what matters is the value vitest
|
|
265
424
|
// resolves, and a `server.deps.inline` naming the wrong package would
|
|
@@ -300,6 +459,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
|
|
|
300
459
|
"README.ja.md",
|
|
301
460
|
"docker-compose.yml",
|
|
302
461
|
"docker-compose.production.yml",
|
|
462
|
+
"docker-compose.mailpit.yml",
|
|
303
463
|
] as const;
|
|
304
464
|
|
|
305
465
|
it("reads only files the template actually ships", () => {
|
|
@@ -325,7 +485,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
|
|
|
325
485
|
}
|
|
326
486
|
});
|
|
327
487
|
|
|
328
|
-
it("ships every setup file vitest.config.mts loads, into the `test` image as well
|
|
488
|
+
it("ships every setup file vitest.config.mts loads, into the `test` image as well", async () => {
|
|
329
489
|
// vitest refuses to start when a `setupFiles` entry is missing, so a setup
|
|
330
490
|
// file the image never copied fails the whole of `make test`, not one test.
|
|
331
491
|
const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
|
|
@@ -333,7 +493,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
|
|
|
333
493
|
default: { test?: { setupFiles?: string | readonly string[] } };
|
|
334
494
|
};
|
|
335
495
|
const setupFiles = [config.test?.setupFiles ?? []].flat();
|
|
336
|
-
expect(setupFiles, "the
|
|
496
|
+
expect(setupFiles, "the supertest loopback guard is not wired in").not.toHaveLength(0);
|
|
337
497
|
|
|
338
498
|
const copied = copiedIntoStage(read("/Dockerfile"), "test");
|
|
339
499
|
const dockerignore = read("/.dockerignore");
|
|
@@ -389,31 +549,46 @@ function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
|
|
|
389
549
|
}
|
|
390
550
|
|
|
391
551
|
/**
|
|
392
|
-
* Whether `.dockerignore` keeps a
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
552
|
+
* Whether `.dockerignore` keeps a file out of the build context, as Docker
|
|
553
|
+
* decides it (moby's patternmatcher): patterns are anchored at the context
|
|
554
|
+
* root, the last matching one wins and a leading `!` re-includes; `*` and `?`
|
|
555
|
+
* stop at a `/`, `**` does not, and `**` followed by `/` also matches no
|
|
556
|
+
* directory at all; a pattern that matches one of the file's parent
|
|
557
|
+
* directories matches the file.
|
|
396
558
|
*/
|
|
397
559
|
function dockerignoreExcludes(dockerignore: string, file: string): boolean {
|
|
560
|
+
const parents = file.split("/").slice(0, -1);
|
|
561
|
+
const candidates = [file, ...parents.map((_, i) => parents.slice(0, i + 1).join("/"))];
|
|
398
562
|
let excluded = false;
|
|
399
563
|
for (const raw of dockerignore.split("\n")) {
|
|
400
564
|
const line = raw.trim();
|
|
401
565
|
if (line === "" || line.startsWith("#")) continue;
|
|
402
566
|
const negated = line.startsWith("!");
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
567
|
+
// Cleaned as Docker cleans a pattern (`filepath.Clean`): a leading `/`
|
|
568
|
+
// or `./` and a trailing `/` say nothing about what it matches.
|
|
569
|
+
const pattern = (negated ? line.slice(1) : line).replace(/^(\.?\/)+/, "").replace(/\/+$/, "");
|
|
570
|
+
let source = "";
|
|
571
|
+
for (let i = 0; i < pattern.length; ) {
|
|
572
|
+
if (pattern.startsWith("**/", i)) {
|
|
573
|
+
source += "(?:.*/)?";
|
|
574
|
+
i += 3;
|
|
575
|
+
} else if (pattern.startsWith("**", i)) {
|
|
576
|
+
source += ".*";
|
|
577
|
+
i += 2;
|
|
578
|
+
} else {
|
|
579
|
+
const char = pattern[i] as string;
|
|
580
|
+
source +=
|
|
581
|
+
char === "*" ? "[^/]*" : char === "?" ? "[^/]" : char.replace(/[.+^${}()|[\]\\]/, "\\$&");
|
|
582
|
+
i += 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const regex = new RegExp(`^${source}$`);
|
|
586
|
+
if (candidates.some((candidate) => regex.test(candidate))) excluded = !negated;
|
|
412
587
|
}
|
|
413
588
|
return excluded;
|
|
414
589
|
}
|
|
415
590
|
|
|
416
|
-
describe("
|
|
591
|
+
describe("the compose files give the process time to finish a shutdown", () => {
|
|
417
592
|
it.each(["/docker-compose.yml", "/docker-compose.production.yml"])(
|
|
418
593
|
"%s declares a stop_grace_period covering the HTTP drain, the grants cleanup and an exit margin",
|
|
419
594
|
(rel) => {
|