@o3co/create-auth-provider 0.17.0 → 0.18.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.
Files changed (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,610 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * Pins the parts of the scaffold that are checkable from inside the
19
- * repository. The scaffold is the artifact an operator actually deploys, and
20
- * a mistake in it stays invisible until someone reads the file: the container
21
- * install losing an allowlist, the dev compose dialling a Redis that is not
22
- * there, one `git add .` committing the signing key.
23
- */
24
-
25
- import { existsSync, readFileSync } from "node:fs";
26
- import { fileURLToPath } from "node:url";
27
- import { BootError } from "@o3co/auth-provider-core";
28
- import { parseFile } from "@o3co/ts.hocon";
29
- import { describe, expect, it } from "vitest";
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";
35
- import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
36
-
37
- const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
38
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
39
- const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
40
-
41
- /**
42
- * The `environment:` entries of a compose file's `app` service, as the
43
- * environment the container would actually see. Hand-rolled rather than a
44
- * YAML dependency: both files are fixed-shape, the block is flat `KEY: value`
45
- * scalars, and the template should not ship a parser to read its own
46
- * scaffold. Compose's `${VAR:?err}` required-variable form resolves from the
47
- * operator's shell or `.env`, so its key is mapped to `null`.
48
- */
49
- function composeAppEnvironment(rel: string): Map<string, string | null> {
50
- const lines = read(rel).split("\n");
51
- const start = lines.findIndex((line) => /^\s{4}environment:\s*$/.test(line));
52
- const env = new Map<string, string | null>();
53
- if (start === -1) return env;
54
- for (const line of lines.slice(start + 1)) {
55
- if (line.trim() === "" || /^\s*#/.test(line)) continue;
56
- // The block ends at the first line indented no further than its own key.
57
- if (!/^\s{6}\S/.test(line)) break;
58
- const match = /^\s{6}([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
59
- if (!match) continue;
60
- const raw = (match[2] as string).trim().replace(/^["']|["']$/g, "");
61
- env.set(match[1] as string, /^\$\{[A-Z][A-Z0-9_]*:\?/.test(raw) ? null : raw);
62
- }
63
- return env;
64
- }
65
-
66
- /** The template's own layers under `env`, over its own reference, unparsed. */
67
- function ownResolved(
68
- env: Record<string, string>,
69
- configEnv = "production",
70
- ): Record<string, unknown> {
71
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
72
- return parseFile(envConfPath, { env })
73
- .withFallback(parseFile(applicationConfPath, { env }))
74
- .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
75
- .toObject() as Record<string, unknown>;
76
- }
77
-
78
- /** `session-store.storage.type` as the template's own layers resolve it under `env`. */
79
- function sessionStorageTypeWith(env: Record<string, string>): unknown {
80
- const section = ownResolved(env)["session-store"] as
81
- | { readonly storage?: { readonly type?: unknown } }
82
- | undefined;
83
- return section?.storage?.type;
84
- }
85
-
86
- /** The adapters phase one reads under `env`. */
87
- function adaptersWith(env: Record<string, string>, configEnv = "production"): Adapters {
88
- return readAdapters(ownResolved(env, configEnv), env);
89
- }
90
-
91
- /**
92
- * A compose file's `environment:` block, plus the secrets no compose file
93
- * carries (they come from `.env` or a compose secret) — the environment the
94
- * process would boot under. The production compose points the EdDSA key
95
- * paths at its mounted secrets, so the default algorithm stays; the dev
96
- * compose supplies no key, so it gets the HS256 shape, whose `.strict()`
97
- * union member is why the two cannot share one map.
98
- */
99
- function bootableEnv(rel: string): Record<string, string> {
100
- const env: Record<string, string> = {
101
- OAUTH_JWT_ISSUER: "https://auth.test",
102
- SESSION_STORE_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
103
- };
104
- for (const [key, value] of composeAppEnvironment(rel)) {
105
- if (value !== null) env[key] = value;
106
- }
107
- if (env.KEY_STORE_LOCAL_PRIVATE_KEY_PATH === undefined) {
108
- env.KEY_STORE_LOCAL_ALGORITHM = "HS256";
109
- env.KEY_STORE_LOCAL_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
110
- }
111
- return env;
112
- }
113
-
114
- describe("the Dockerfile installs with everything pnpm needs", () => {
115
- it("copies pnpm-workspace.yaml into the deps stage", () => {
116
- // `create-auth-provider` generates it to carry the bcrypt
117
- // `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
118
- // setting ONLY from this file — in single-package projects too. A deps
119
- // stage without it runs allowlist-less, which leaves bcrypt unbuilt the
120
- // day an alpine prebuild is missing and it has to compile.
121
- const dockerfile = read("/Dockerfile");
122
- const depsStage = dockerfile.slice(
123
- dockerfile.indexOf("FROM node-base AS deps"),
124
- dockerfile.indexOf("FROM", dockerfile.indexOf("FROM node-base AS deps") + 1),
125
- );
126
- // Matched as a COPY instruction, not as a substring of the stage: the
127
- // comment above that COPY names the file too, so a substring check
128
- // would keep passing without the instruction.
129
- const copyLines = depsStage
130
- .split("\n")
131
- .filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
132
- expect(copyLines.some((line) => /\bpnpm-workspace\.yaml\b/.test(line))).toBe(true);
133
- });
134
- });
135
-
136
- describe("the dev compose can reach every Redis it configures", () => {
137
- it("sets every *_REDIS_URL the template config reads", () => {
138
- // The template's application.conf and its own reference.conf substitute
139
- // several Redis URLs, each defaulting to `redis://localhost:6379` —
140
- // which inside the container is the container itself. `.env.example` is
141
- // what the compose file loads, so a URL missing from it is a boot that
142
- // dials nothing.
143
- // The captures of renamed variables (`renamed-variables { … }`) read an
144
- // old name only to refuse it, and dial nothing.
145
- const conf = (read("/config/application.conf") + read("/config/reference.conf")).replace(
146
- /^renamed-variables \{[\s\S]*?^\}/m,
147
- "",
148
- );
149
- const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
150
- expect(declared.length).toBeGreaterThan(0);
151
-
152
- const env = read("/.env.example");
153
- const missing = [...new Set(declared)].filter(
154
- (name) => !new RegExp(`^${name}=`, "m").test(env),
155
- );
156
- expect(missing).toEqual([]);
157
- });
158
- });
159
-
160
- describe("the scaffold does not invite committing its own secrets", () => {
161
- it("ships a .gitignore", () => {
162
- // Without one, the first `git add .` in a scaffolded project commits
163
- // `.env` and `jwt-private.pem` — both of which the README tells the
164
- // operator to create right there.
165
- expect(existsSync(`${standaloneDir}/.gitignore`)).toBe(true);
166
- });
167
-
168
- it("ignores the files the scaffold's own setup steps create", () => {
169
- const ignored = read("/.gitignore");
170
- for (const pattern of [".env", "*.pem", "node_modules", "dist"]) {
171
- expect(ignored).toContain(pattern);
172
- }
173
- });
174
-
175
- it("keeps .env.example tracked — it is the documentation", () => {
176
- // A bare `.env*` would take the example with it, which is the mistake
177
- // this pins against.
178
- expect(read("/.gitignore")).toMatch(/^!\.env\.example$/m);
179
- });
180
- });
181
-
182
- describe("the production compose matches the topology it documents", () => {
183
- it("does not publish the app port on every interface", () => {
184
- // The README says to keep `/metrics` off the public listener and the
185
- // file assumes TLS is terminated in front, so a bare "3000:3000"
186
- // contradicts both — it binds 0.0.0.0 on the host.
187
- const compose = read("/docker-compose.production.yml");
188
- expect(compose).not.toMatch(/^\s*-\s*"3000:3000"\s*$/m);
189
- expect(compose).toMatch(/127\.0\.0\.1:3000:3000/);
190
- });
191
- });
192
-
193
- /**
194
- * What `.gitignore` keeps out of git (secrets and per-machine configuration,
195
- * the client registry among them) stays out of the build context too: the
196
- * `Dockerfile` copies `config/`, so a `config/clients.yaml` in the working
197
- * copy, client secrets included, would be baked into the runtime image.
198
- * Production is given the registry from outside the image instead.
199
- */
200
- describe("what .gitignore keeps out of git stays out of the image, and production is given it", () => {
201
- /**
202
- * `.gitignore`'s "Secrets and per-machine configuration" section: each
203
- * pattern, with files it matches. A pattern without a `/` matches at any
204
- * depth in `.gitignore`, so those carry a nested file too — `.dockerignore`
205
- * anchors every pattern at the context root, where `*` stops at a `/`.
206
- */
207
- const PER_DEPLOYMENT: Readonly<Record<string, readonly string[]>> = {
208
- ".env": [".env", "config/.env"],
209
- ".env.*": [".env.local", ".env.production", "config/.env.production"],
210
- "*.pem": ["jwt-private.pem", "config/jwt-private.pem"],
211
- "*.key": ["tls.key", "config/tls.key"],
212
- "config/clients.yaml": ["config/clients.yaml"],
213
- "config/*.local.conf": ["config/production.local.conf"],
214
- };
215
-
216
- /**
217
- * The committed files beside them, which the image does need. `.env.example`
218
- * is what `.gitignore` re-includes, at any depth: the same here.
219
- */
220
- const COMMITTED = [
221
- ".env.example",
222
- "config/.env.example",
223
- "config/application.conf",
224
- "config/development.conf",
225
- "config/production.conf",
226
- "config/mailpit.conf",
227
- "config/clients.yaml.example",
228
- ] as const;
229
-
230
- it("checks every pattern of .gitignore's secrets section", () => {
231
- const lines = read("/.gitignore").split("\n");
232
- const start = lines.indexOf("# Secrets and per-machine configuration");
233
- expect(start).not.toBe(-1);
234
- const section: string[] = [];
235
- for (const line of lines.slice(start + 1)) {
236
- if (line.trim() === "") break;
237
- if (!line.startsWith("#") && !line.startsWith("!")) section.push(line.trim());
238
- }
239
- expect(section).toEqual(Object.keys(PER_DEPLOYMENT));
240
- });
241
-
242
- it.each(Object.entries(PER_DEPLOYMENT))(
243
- "keeps what %s matches out of the build context",
244
- (_, files) => {
245
- const dockerignore = read("/.dockerignore");
246
- for (const file of files) {
247
- expect(dockerignoreExcludes(dockerignore, file), `${file} reaches the build context`).toBe(
248
- true,
249
- );
250
- }
251
- },
252
- );
253
-
254
- it("lets the committed configuration through", () => {
255
- const dockerignore = read("/.dockerignore");
256
- for (const file of COMMITTED) {
257
- expect(dockerignoreExcludes(dockerignore, file), `${file} is excluded`).toBe(false);
258
- }
259
- });
260
-
261
- it("gives the production compose's process the client registry, read-only, from outside the image", () => {
262
- // A compose secret, like the key pair: mounted read-only under
263
- // /run/secrets, never part of a layer.
264
- const name = "client_registry";
265
- expect(composeAppSecrets("/docker-compose.production.yml")).toContain(name);
266
- expect(composeSecretFiles("/docker-compose.production.yml").get(name)).toBe(
267
- "./config/clients.yaml",
268
- );
269
- // And the process reads it there, through the real config layers.
270
- const env = bootableEnv("/docker-compose.production.yml");
271
- expect(adaptersWith(env).clientRepository).toBe("yaml");
272
- expect(repositoriesSectionSchema.parse(ownResolved(env).repositories).client.yaml.path).toBe(
273
- `/run/secrets/${name}`,
274
- );
275
- });
276
- });
277
-
278
- /** The secrets a compose file's `app` service mounts. */
279
- function composeAppSecrets(rel: string): string[] {
280
- const lines = read(rel).split("\n");
281
- const start = lines.findIndex((line) => /^\s{4}secrets:\s*$/.test(line));
282
- const names: string[] = [];
283
- if (start === -1) return names;
284
- for (const line of lines.slice(start + 1)) {
285
- if (line.trim() === "" || /^\s*#/.test(line)) continue;
286
- const match = /^\s{6}-\s*(\S+)\s*$/.exec(line);
287
- if (!match) break;
288
- names.push(match[1] as string);
289
- }
290
- return names;
291
- }
292
-
293
- /** A compose file's top-level `secrets:`, as each name's `file:`. */
294
- function composeSecretFiles(rel: string): Map<string, string> {
295
- const lines = read(rel).split("\n");
296
- const start = lines.findIndex((line) => /^secrets:\s*$/.test(line));
297
- const files = new Map<string, string>();
298
- if (start === -1) return files;
299
- let name: string | undefined;
300
- for (const line of lines.slice(start + 1)) {
301
- if (line.trim() === "" || /^\s*#/.test(line)) continue;
302
- if (!/^\s/.test(line)) break;
303
- const entry = /^\s{2}([A-Za-z0-9_-]+):\s*$/.exec(line);
304
- if (entry) {
305
- name = entry[1];
306
- continue;
307
- }
308
- const file = /^\s{4}file:\s*(\S+)\s*$/.exec(line);
309
- if (file && name !== undefined) files.set(name, file[1] as string);
310
- }
311
- return files;
312
- }
313
-
314
- describe("the compose files put a store and its lifetime-sibling on the same backend", () => {
315
- // With express-session on Redis and the user-session stores on memory,
316
- // express-session survives a restart and the `UserSession` it points at
317
- // does not: every browser comes back `isAuthenticated` with nothing behind
318
- // it and /authorize loops until the cookie is deleted by hand.
319
- // `CORE_DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
320
- // guard answers "can these stores be shared", not "do these two stores have
321
- // the same lifetime" — so nothing but this assertion stands behind it.
322
- for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
323
- it(`${file} keeps the user-session stores with the express-session store`, () => {
324
- const env = bootableEnv(file);
325
- // Resolved through the real config layers, not read off the file:
326
- // what matters is the value the process ends up with, whether the
327
- // compose stated it or `config/application.conf` did.
328
- expect(sessionStorageTypeWith(env)).toBe("redis");
329
- expect(adaptersWith(env).userSessionStores).toBe("redis");
330
- });
331
- }
332
-
333
- it("the production compose leaves no store on memory while a sibling is on Redis", () => {
334
- const env = bootableEnv("/docker-compose.production.yml");
335
- // Every store whose records must outlive one process. The federation
336
- // token store is deliberately absent: this template ships every
337
- // federation disabled, so nothing writes to it, and turning it on needs
338
- // an AES key the compose file must not invent.
339
- expect(sessionStorageTypeWith(env)).toBe("redis");
340
- expect(adaptersWith(env)).toMatchObject({
341
- userSessionStores: "redis",
342
- codeRepository: "redis",
343
- accessTokenDenylist: "redis",
344
- rateLimiter: "redis",
345
- });
346
- });
347
- });
348
-
349
- /** `.env.example`'s uncommented lines that carry a value: what a `.env` copied from it sets. */
350
- function dotenvExample(): Record<string, string> {
351
- const env: Record<string, string> = {};
352
- for (const line of read("/.env.example").split("\n")) {
353
- const match = /^([A-Z][A-Z0-9_]*)=(.+)$/.exec(line);
354
- if (match) env[match[1] as string] = match[2] as string;
355
- }
356
- return env;
357
- }
358
-
359
- describe("the production compose leaves MFA to the configuration's switch, for the deployment to decide", () => {
360
- it("reads the switch as the configuration's files write it: on, a deployment that sets nothing about MFA is refused naming MFA_MODE=off; off, it installs nothing of MFA", () => {
361
- // `environment:` wins over `env_file`, so the compose file's block is
362
- // laid over the `.env` copied from `.env.example`.
363
- const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
364
- // `app.mts`: CONFIG_ENV, then NODE_ENV, then development.
365
- const configEnv = env.CONFIG_ENV || env.NODE_ENV || "development";
366
- expect(configEnv).toBe("production");
367
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
368
- const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
369
- const mode = configuredMfaMode(configEnv);
370
- expect(switches.mfaMode).toBe(mode);
371
-
372
- if (mode === "off") {
373
- const names = buildModules(switches, { environment: configEnv }).map((m) => m.name);
374
- expect(names.filter((name) => /mfa/i.test(name))).toEqual([]);
375
- return;
376
- }
377
- let err: unknown;
378
- try {
379
- buildModules(switches, { environment: configEnv });
380
- } catch (caught) {
381
- err = caught;
382
- }
383
- expect(err).toBeInstanceOf(BootError);
384
- expect((err as BootError).reason).toBe("config-validation-failed");
385
- expect((err as BootError).message).toContain("MFA_MODE=off");
386
- });
387
- });
388
-
389
- describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
390
- it("names the variable and supplies no default", () => {
391
- // Without it the Secure cookie is never set, the CSRF origin check 403s
392
- // every browser POST, and every IP-keyed rate limit shares one bucket.
393
- // With a baked-in default it would silently trust a hop the operator
394
- // never chose. The `${VAR:?err}` form is the only reading that is
395
- // neither: compose refuses to start until the operator names their edge.
396
- const compose = composeAppEnvironment("/docker-compose.production.yml");
397
- expect(compose.has("HTTP_TRUST_PROXY")).toBe(true);
398
- expect(compose.get("HTTP_TRUST_PROXY")).toBeNull();
399
- expect(read("/docker-compose.production.yml")).not.toMatch(
400
- /HTTP_TRUST_PROXY:\s*(true|false)\b/,
401
- );
402
- });
403
-
404
- it("is offered in .env.example with no value", () => {
405
- // The compose reads it from `.env`, which the operator copies from here.
406
- expect(read("/.env.example")).toMatch(/^HTTP_TRUST_PROXY=$/m);
407
- });
408
- });
409
-
410
- describe("the READMEs' security advice", () => {
411
- it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
412
- // `trust proxy` is a CIDR/hop policy because `true` means "believe the
413
- // leftmost forwarded entry from whoever opened the connection". The
414
- // English README warns against it, and the Japanese one must agree.
415
- expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
416
- });
417
- });
418
-
419
- /**
420
- * The suite that ships with a scaffolded project has to be green in that
421
- * project, not only in this workspace. A scaffold installs
422
- * `@o3co/auth-provider-*` from npm, under `node_modules`, where vitest
423
- * externalizes them and their own `import "ioredis"` / `import "redis"` never
424
- * meet the `vi.mock` registry unless inlined; here they are symlinks vitest
425
- * inlines anyway. And `make test` runs this suite inside the `test` image,
426
- * where a file the Dockerfile never copied does not exist.
427
- */
428
- describe("the shipped suite carries what it needs to run outside this repository", () => {
429
- it("runs the published packages through vitest, so module mocks reach them", async () => {
430
- // The config is loaded, not grepped: what matters is the value vitest
431
- // resolves, and a `server.deps.inline` naming the wrong package would
432
- // satisfy a substring check just as well.
433
- const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
434
- const { default: config } = (await import(configPath)) as {
435
- default: {
436
- test?: { server?: { deps?: { inline?: ReadonlyArray<string | RegExp> | true } } };
437
- };
438
- };
439
- const inline = config.test?.server?.deps?.inline;
440
- expect(inline).toBeDefined();
441
- if (inline === true) return;
442
- for (const name of [
443
- "@o3co/auth-provider-core",
444
- "@o3co/auth-provider-redis",
445
- "@o3co/auth-provider-session",
446
- ]) {
447
- expect(
448
- (inline ?? []).some((m) => (m instanceof RegExp ? m.test(name) : m === name)),
449
- `${name} is not inlined`,
450
- ).toBe(true);
451
- }
452
- });
453
-
454
- /**
455
- * Every file this suite reads from the project root. `config/` and `src/`
456
- * reach the image through the builder stage; these do not, so the `test`
457
- * stage has to bring them — and `.dockerignore` has to let them into the
458
- * build context first.
459
- */
460
- const READ_FROM_THE_PROJECT_ROOT = [
461
- "Dockerfile",
462
- ".dockerignore",
463
- ".env.example",
464
- ".gitignore",
465
- "README.md",
466
- "README.ja.md",
467
- "docker-compose.yml",
468
- "docker-compose.production.yml",
469
- "docker-compose.mailpit.yml",
470
- ] as const;
471
-
472
- it("reads only files the template actually ships", () => {
473
- for (const file of READ_FROM_THE_PROJECT_ROOT) {
474
- expect(existsSync(`${standaloneDir}${file}`), file).toBe(true);
475
- }
476
- });
477
-
478
- it("copies each of them into the image the `test` target runs", () => {
479
- const copied = copiedIntoStage(read("/Dockerfile"), "test");
480
- for (const file of READ_FROM_THE_PROJECT_ROOT) {
481
- expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
482
- }
483
- });
484
-
485
- it("lets each of them through .dockerignore", () => {
486
- const dockerignore = read("/.dockerignore");
487
- for (const file of READ_FROM_THE_PROJECT_ROOT) {
488
- expect(
489
- dockerignoreExcludes(dockerignore, file),
490
- `${file} is excluded from the build context`,
491
- ).toBe(false);
492
- }
493
- });
494
-
495
- it("ships every setup file vitest.config.mts loads, into the `test` image as well", async () => {
496
- // vitest refuses to start when a `setupFiles` entry is missing, so a setup
497
- // file the image never copied fails the whole of `make test`, not one test.
498
- const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
499
- const { default: config } = (await import(configPath)) as {
500
- default: { test?: { setupFiles?: string | readonly string[] } };
501
- };
502
- const setupFiles = [config.test?.setupFiles ?? []].flat();
503
- expect(setupFiles, "the supertest loopback guard is not wired in").not.toHaveLength(0);
504
-
505
- const copied = copiedIntoStage(read("/Dockerfile"), "test");
506
- const dockerignore = read("/.dockerignore");
507
- for (const entry of setupFiles) {
508
- const file = entry.replace(/^\.\//, "");
509
- expect(existsSync(`${standaloneDir}${file}`), `${file} does not exist`).toBe(true);
510
- expect(copied.has(file), `${file} is not copied into the test image`).toBe(true);
511
- expect(
512
- dockerignoreExcludes(dockerignore, file),
513
- `${file} is excluded from the build context`,
514
- ).toBe(false);
515
- }
516
- });
517
- });
518
-
519
- /**
520
- * The build-context sources every `COPY` in `stage` and its ancestors brings
521
- * into the image. `COPY --from=` is skipped — it reads another stage, not
522
- * the context — and `--chown` / other flags are dropped. Sources are
523
- * normalised to the bare entry name (`./config/` → `config`).
524
- */
525
- function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
526
- const stages = new Map<string, { parent: string; sources: string[] }>();
527
- let current: { parent: string; sources: string[] } | undefined;
528
- // Continuation lines are joined first, so a multi-line instruction reads
529
- // as one.
530
- for (const line of dockerfile.replace(/\\\n/g, " ").split("\n")) {
531
- const text = line.trim();
532
- if (text === "" || text.startsWith("#")) continue;
533
- const from = /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(text);
534
- if (from) {
535
- const parent = from[1] as string;
536
- current = { parent, sources: [] };
537
- stages.set(from[2] ?? parent, current);
538
- continue;
539
- }
540
- if (!current || !/^COPY\b/i.test(text)) continue;
541
- const args = text.split(/\s+/).slice(1);
542
- if (args.some((a) => a.startsWith("--from="))) continue;
543
- const operands = args.filter((a) => !a.startsWith("--"));
544
- for (const source of operands.slice(0, -1)) {
545
- current.sources.push(source.replace(/^\.\//, "").replace(/\/$/, ""));
546
- }
547
- }
548
- const copied = new Set<string>();
549
- for (let name: string | undefined = stage; name !== undefined; ) {
550
- const s = stages.get(name);
551
- if (!s) break;
552
- for (const source of s.sources) copied.add(source);
553
- name = s.parent;
554
- }
555
- return copied;
556
- }
557
-
558
- /**
559
- * Whether `.dockerignore` keeps a file out of the build context, as Docker
560
- * decides it (moby's patternmatcher): patterns are anchored at the context
561
- * root, the last matching one wins and a leading `!` re-includes; `*` and `?`
562
- * stop at a `/`, `**` does not, and `**` followed by `/` also matches no
563
- * directory at all; a pattern that matches one of the file's parent
564
- * directories matches the file.
565
- */
566
- function dockerignoreExcludes(dockerignore: string, file: string): boolean {
567
- const parents = file.split("/").slice(0, -1);
568
- const candidates = [file, ...parents.map((_, i) => parents.slice(0, i + 1).join("/"))];
569
- let excluded = false;
570
- for (const raw of dockerignore.split("\n")) {
571
- const line = raw.trim();
572
- if (line === "" || line.startsWith("#")) continue;
573
- const negated = line.startsWith("!");
574
- // Cleaned as Docker cleans a pattern (`filepath.Clean`): a leading `/`
575
- // or `./` and a trailing `/` say nothing about what it matches.
576
- const pattern = (negated ? line.slice(1) : line).replace(/^(\.?\/)+/, "").replace(/\/+$/, "");
577
- let source = "";
578
- for (let i = 0; i < pattern.length; ) {
579
- if (pattern.startsWith("**/", i)) {
580
- source += "(?:.*/)?";
581
- i += 3;
582
- } else if (pattern.startsWith("**", i)) {
583
- source += ".*";
584
- i += 2;
585
- } else {
586
- const char = pattern[i] as string;
587
- source +=
588
- char === "*" ? "[^/]*" : char === "?" ? "[^/]" : char.replace(/[.+^${}()|[\]\\]/, "\\$&");
589
- i += 1;
590
- }
591
- }
592
- const regex = new RegExp(`^${source}$`);
593
- if (candidates.some((candidate) => regex.test(candidate))) excluded = !negated;
594
- }
595
- return excluded;
596
- }
597
-
598
- describe("the compose files give the process time to finish a shutdown", () => {
599
- it.each(["/docker-compose.yml", "/docker-compose.production.yml"])(
600
- "%s declares a stop_grace_period covering the HTTP drain, the grants cleanup and an exit margin",
601
- (rel) => {
602
- // compose's own default is ten seconds — the drain budget alone. With
603
- // federation grants on, the cleanup that drains a rotated credential's
604
- // write gets 45 more, and SIGKILL must arrive after that, not during.
605
- const match = /^\s+stop_grace_period:\s*(\d+)s\s*$/m.exec(read(rel));
606
- expect(match, `${rel} has no stop_grace_period`).not.toBeNull();
607
- expect(Number(match?.[1])).toBeGreaterThanOrEqual(60);
608
- },
609
- );
610
- });