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