@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.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- 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
|
-
});
|