configre 2.0.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -10
- package/demo/secrets/demo.js +1 -1
- package/index.js +5 -12
- package/package.json +1 -1
- package/secrets/identity.js +6 -1
- package/secrets/index.js +44 -38
- package/secrets/log.js +3 -0
- package/secrets/register.js +12 -3
- package/test/secrets.test.js +143 -47
package/README.md
CHANGED
|
@@ -119,10 +119,10 @@ import Configre from "../../index.js";
|
|
|
119
119
|
import { join } from "node:path";
|
|
120
120
|
|
|
121
121
|
const configPath = join(import.meta.dirname, "config");
|
|
122
|
-
const cfg = Configre(configPath
|
|
122
|
+
const cfg = Configre(configPath);
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
`
|
|
125
|
+
Secrets activate automatically when the base configuration or selected profile has a corresponding `.secret.cjs` file, or when `secrets.enc.json` already exists. No options are needed. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
|
|
126
126
|
|
|
127
127
|
> The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
|
|
128
128
|
|
|
@@ -138,18 +138,24 @@ module.exports = {
|
|
|
138
138
|
};
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
+
Create `demo/secrets/config/index.secret.cjs` yourself to enable secrets, initially with:
|
|
142
|
+
|
|
143
|
+
```javascript
|
|
144
|
+
module.exports = {};
|
|
145
|
+
```
|
|
146
|
+
|
|
141
147
|
Then run:
|
|
142
148
|
|
|
143
149
|
```bash
|
|
144
150
|
node demo/secrets/demo.js
|
|
145
151
|
```
|
|
146
152
|
|
|
147
|
-
For a new setup, Configre
|
|
153
|
+
For a new setup, Configre generates the encrypted file, recipients directory and Git exclusions alongside your existing configuration files:
|
|
148
154
|
|
|
149
155
|
```text
|
|
150
156
|
demo/secrets/config/
|
|
151
157
|
index.cjs # Public configuration
|
|
152
|
-
index.secret.cjs # Editable secrets
|
|
158
|
+
index.secret.cjs # Editable secrets you create yourself
|
|
153
159
|
recipients/ # Public keys of servers that register
|
|
154
160
|
secrets.enc.json # Generated encrypted values
|
|
155
161
|
.gitignore # Keeps .secret.cjs files out of Git
|
|
@@ -163,7 +169,7 @@ With the public placeholder empty and no secrets added, look for:
|
|
|
163
169
|
API key configured: false
|
|
164
170
|
```
|
|
165
171
|
|
|
166
|
-
|
|
172
|
+
**Configre never creates `.secret.cjs` files.** Without a secret counterpart for the base configuration or selected profile, and without an encrypted file, it loads only public settings and creates no identity or secret artifacts. A secret module contains only the fields you choose to override; public settings continue to come from `index.cjs`.
|
|
167
173
|
|
|
168
174
|
These initialization steps describe a new setup. If `secrets.enc.json` already exists, Configre uses that encrypted file instead of resetting it. A checkout containing the encrypted file but no `.secret.cjs` files is treated as a server checkout.
|
|
169
175
|
|
|
@@ -218,9 +224,11 @@ If this server is not authorized yet, Configre automatically:
|
|
|
218
224
|
1. Creates its local identity, if needed.
|
|
219
225
|
2. Writes `demo/secrets/config/recipients/truco.pub`.
|
|
220
226
|
3. Creates and pushes a commit containing only that public-key file.
|
|
221
|
-
4.
|
|
227
|
+
4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
|
|
228
|
+
|
|
229
|
+
Publishing a public key does not let the server decrypt the existing ciphertext. While authorization is pending, `cfg` preserves the public defaults and selected public profile; fields that exist only in the encrypted file are absent. Repeating the command does not publish another registration commit for the same key. If Git registration fails, Configre logs a warning and still returns public settings; the next Configre load retries registration.
|
|
222
230
|
|
|
223
|
-
|
|
231
|
+
Configre uses `log.info` when it creates identity files, writes Git exclusions, creates the recipients directory or a public-key file, updates the encrypted file, and successfully pushes the public key. Messages contain operation names and file paths, never secret values or key contents. These messages use the existing `Configre` LemonLog namespace; use `DEBUG=Configre:*` to display them. Unchanged files and previously published public keys do not generate another creation or publication log.
|
|
224
232
|
|
|
225
233
|
### 4. Include the server in the encrypted file
|
|
226
234
|
|
|
@@ -254,7 +262,7 @@ node demo/secrets/demo.js
|
|
|
254
262
|
|
|
255
263
|
Commit and push the updated `secrets.enc.json`, then pull and restart the demo on the server. **You do not need to copy or register `truco.pub` again.** Its existing authorization also covers new keys you add later.
|
|
256
264
|
|
|
257
|
-
For settings specific to `truco`, add `demo/secrets/config/truco.cjs
|
|
265
|
+
For settings specific to `truco`, add `demo/secrets/config/truco.cjs` and create `truco.secret.cjs` yourself with that profile's secrets. The secret file activates secrets when `truco` is selected, even without `index.secret.cjs`. A public profile alone never creates a secret counterpart. Once secrets are active, all profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
|
|
258
266
|
|
|
259
267
|
For `--config=truco`, the merge order is:
|
|
260
268
|
|
|
@@ -271,11 +279,11 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
|
|
|
271
279
|
|
|
272
280
|
- Secret `.cjs` modules execute on the administrator and are reloaded on each Configre call. They must export plain objects containing JSON-compatible objects, arrays, strings, finite numbers, booleans and `null`. Functions, `undefined`, accessors, symbols, custom objects, circular references, and properties named `__proto__`, `constructor` or `prototype` are rejected. Consumers decrypt data without executing these modules.
|
|
273
281
|
- Empty strings stay empty strings. Configre does not fill them from environment variables or populate `process.env`. The existing deep-merge behavior, including array merging, also applies to secrets.
|
|
274
|
-
-
|
|
282
|
+
- Activation depends only on the existing files; there is no `secrets` option. The same behavior applies to `new Configre(configPath).get()` and CommonJS consumers.
|
|
275
283
|
- Each OS user has one identity reused across projects; authorization is per project. A service running under another OS user needs its own registration. All authorized identities can decrypt all profiles in the project's encrypted file.
|
|
276
284
|
- Public recipient files must contain a single RSA-3072 public key in PEM format, with exponent 65537, as generated by Configre. Other file extensions are ignored, and duplicate keys do not add recipients. The administrator is always included.
|
|
277
285
|
- Registration uses an isolated Git index and publishes only the public-key file. It preserves unrelated staged and unstaged changes, runs no commit or pre-push hooks, and does not push local tags, merge, rebase or force-push. Failed registrations can be retried at the next startup; each Git command has a 30-second timeout. An authorized server loads secrets without Git commands or project writes.
|
|
278
|
-
- Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`,
|
|
286
|
+
- Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, registration stops without overwriting it, and configuration loading continues with a warning and public settings only; use a distinct profile or resolve the key replacement explicitly.
|
|
279
287
|
- Configre appends Git exclusions without removing existing rules and refuses to proceed on the administrator if editable secret modules are already tracked. It does not untrack files or rewrite history. It also prepares `.gitignore` when no repository exists yet.
|
|
280
288
|
- The administrator's `.secret.cjs` files are the source of truth for values, and `recipients/` is the source of truth for authorization. Removing all recipient files revokes them on the next administrator reload; old ciphertext does not restore them. Recover accidentally deleted public-key files from Git before reloading. Missing Git exclusions and ciphertext are regenerated on the administrator, and existing secret modules are never overwritten.
|
|
281
289
|
- Back up the administrator's editable secrets and private identity securely. Never share or commit `identity.pem`. On POSIX, its directory must have owner-only permissions (`0700`) and the private file must have owner-only permissions (`0600`); on Windows, protection depends on the user profile's filesystem permissions. Private identities and secret input files cannot be symlinks.
|
package/demo/secrets/demo.js
CHANGED
|
@@ -2,7 +2,7 @@ import Configre from "../../index.js";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
const configPath = join(import.meta.dirname, "config");
|
|
5
|
-
const cfg = Configre(configPath
|
|
5
|
+
const cfg = Configre(configPath);
|
|
6
6
|
console.info("API key configured:", Boolean(cfg.api.key));
|
|
7
7
|
console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
|
|
8
8
|
console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
|
package/index.js
CHANGED
|
@@ -10,15 +10,10 @@ const requireConfig = createRequire(import.meta.url);
|
|
|
10
10
|
const log = lemonlog("Configre");
|
|
11
11
|
|
|
12
12
|
class ConfigreClass {
|
|
13
|
-
constructor(pathOrDir
|
|
13
|
+
constructor(pathOrDir) {
|
|
14
14
|
if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
|
|
15
15
|
throw new TypeError("Configre path must be a non-empty string");
|
|
16
16
|
}
|
|
17
|
-
if (options === null || typeof options !== "object" || Array.isArray(options) ||
|
|
18
|
-
(options.secrets !== undefined && typeof options.secrets !== "boolean")) {
|
|
19
|
-
throw new TypeError("Configre options must be an object with an optional boolean secrets property");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
17
|
const dir = path.join(pathOrDir);
|
|
23
18
|
const isNested = (ConfigreClass._nesting || 0) > 0;
|
|
24
19
|
ConfigreClass._nesting = (ConfigreClass._nesting || 0) + 1;
|
|
@@ -35,9 +30,7 @@ class ConfigreClass {
|
|
|
35
30
|
const configArg = process.argv.find(arg => arg.startsWith('--config='));
|
|
36
31
|
this.profile = configArg ? configArg.slice('--config='.length) : os.hostname();
|
|
37
32
|
this.profileSettings = this.loadProfileSettings();
|
|
38
|
-
this.secretSettings =
|
|
39
|
-
? loadSecrets(pathOrDir, this.configFile, this.profile)
|
|
40
|
-
: [];
|
|
33
|
+
this.secretSettings = loadSecrets(pathOrDir, this.configFile, this.profile);
|
|
41
34
|
} finally {
|
|
42
35
|
ConfigreClass._nesting -= 1;
|
|
43
36
|
}
|
|
@@ -93,11 +86,11 @@ class ConfigreClass {
|
|
|
93
86
|
}
|
|
94
87
|
|
|
95
88
|
// Wrapper function to support both constructor and function usage
|
|
96
|
-
function Configre(path
|
|
89
|
+
function Configre(path) {
|
|
97
90
|
if (this instanceof Configre) {
|
|
98
|
-
return new ConfigreClass(path
|
|
91
|
+
return new ConfigreClass(path);
|
|
99
92
|
} else {
|
|
100
|
-
return new ConfigreClass(path
|
|
93
|
+
return new ConfigreClass(path).get();
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
package/package.json
CHANGED
package/secrets/identity.js
CHANGED
|
@@ -3,12 +3,15 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { generatePrivateKey, parsePrivateKey, parsePublicKey } from "./crypto.js";
|
|
5
5
|
import { stat, readText, withLock } from "./files.js";
|
|
6
|
+
import log from "./log.js";
|
|
6
7
|
|
|
7
8
|
function loadIdentity() {
|
|
8
9
|
const directory = path.join(os.homedir(), ".config", "configre");
|
|
9
10
|
const privatePath = path.join(directory, "identity.pem");
|
|
10
11
|
const publicPath = path.join(directory, "identity.pub");
|
|
11
|
-
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
12
|
+
if (fs.mkdirSync(directory, { recursive: true, mode: 0o700 })) {
|
|
13
|
+
log.info("Created identity directory", directory);
|
|
14
|
+
}
|
|
12
15
|
const info = stat(directory);
|
|
13
16
|
if (!info.isDirectory() || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
|
|
14
17
|
throw new Error(`Configre secrets: identity directory must be private and not a symlink at ${directory}`);
|
|
@@ -32,10 +35,12 @@ function loadIdentity() {
|
|
|
32
35
|
throw new Error("Configre secrets: private identity is missing; restore it instead of replacing it");
|
|
33
36
|
}
|
|
34
37
|
fs.writeFileSync(privatePath, generatePrivateKey(), { flag: "wx", mode: 0o600 });
|
|
38
|
+
log.info("Created private identity file", privatePath);
|
|
35
39
|
}
|
|
36
40
|
const identity = parsePrivateKey(readText(privatePath, true));
|
|
37
41
|
if (!stat(publicPath)) {
|
|
38
42
|
fs.writeFileSync(publicPath, identity.publicKey, { flag: "wx", mode: 0o644 });
|
|
43
|
+
log.info("Created public identity file", publicPath);
|
|
39
44
|
}
|
|
40
45
|
return readIdentity();
|
|
41
46
|
});
|
package/secrets/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import loadIdentity from "./identity.js";
|
|
7
7
|
import registerRecipient from "./register.js";
|
|
8
|
+
import log from "./log.js";
|
|
8
9
|
import { parsePublicKey, validateSecrets, encrypt, decrypt } from "./crypto.js";
|
|
9
10
|
import { stat, readText, readJSON, withLock, writeEncrypted } from "./files.js";
|
|
10
11
|
|
|
@@ -63,11 +64,15 @@ function prepareIgnore(paths) {
|
|
|
63
64
|
const lines = existing.split(/\r?\n/).filter(line => line && !line.startsWith("#"));
|
|
64
65
|
if (!isDeepStrictEqual(lines.slice(-rules.length), rules)) {
|
|
65
66
|
fs.appendFileSync(ignorePath, (existing && !existing.endsWith("\n") ? "\n" : "") + rules.join("\n") + "\n");
|
|
67
|
+
log.info("Wrote Git exclusions for secret files", ignorePath);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function loadRecipients(directory, identity) {
|
|
70
|
-
if (!stat(directory))
|
|
72
|
+
if (!stat(directory)) {
|
|
73
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
74
|
+
log.info("Created recipients directory", directory);
|
|
75
|
+
}
|
|
71
76
|
if (!stat(directory).isDirectory()) {
|
|
72
77
|
throw new Error("Configre secrets: recipients must be a directory, not a symlink");
|
|
73
78
|
}
|
|
@@ -122,51 +127,52 @@ function selectSecrets(bundle, paths, profile) {
|
|
|
122
127
|
|
|
123
128
|
function loadSecrets(configPath, configFile, profile) {
|
|
124
129
|
const paths = secretPaths(configPath, configFile);
|
|
130
|
+
const names = localFiles(paths);
|
|
131
|
+
const hasEncrypted = !!stat(paths.encrypted);
|
|
132
|
+
const hasLocal = names.includes(path.basename(paths.local)) || (paths.directory &&
|
|
133
|
+
(names.includes(profile + ".dev.secret.cjs") || names.includes(profile + ".secret.cjs")));
|
|
134
|
+
if (!hasLocal && !hasEncrypted) return [];
|
|
135
|
+
|
|
125
136
|
const identity = loadIdentity();
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
try {
|
|
138
|
+
if (names.length === 0 && hasEncrypted) {
|
|
128
139
|
return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
|
|
131
|
-
registerRecipient(paths, identity, profile);
|
|
132
|
-
throw new Error("Configre secrets: not authorized yet; public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and restart this machine");
|
|
133
140
|
}
|
|
134
|
-
}
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
const bundle = withLock(paths.encrypted, () => {
|
|
143
|
+
const names = localFiles(paths);
|
|
144
|
+
const hasEncrypted = !!stat(paths.encrypted);
|
|
145
|
+
const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
|
|
146
|
+
const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
|
|
147
|
+
if (names.length === 0 && hasEncrypted) return previous;
|
|
142
148
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
templates.add(name.slice(0, -4) + ".secret.cjs");
|
|
149
|
+
prepareIgnore(paths);
|
|
150
|
+
const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
|
|
151
|
+
const recipients = loadRecipients(paths.recipients, identity);
|
|
152
|
+
if (hasEncrypted) {
|
|
153
|
+
const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
|
|
154
|
+
if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
|
|
155
|
+
return settings;
|
|
151
156
|
}
|
|
152
157
|
}
|
|
158
|
+
writeEncrypted(paths.encrypted, encrypt(settings, recipients));
|
|
159
|
+
log.info(hasEncrypted ? "Updated encrypted secrets file" : "Created encrypted secrets file", paths.encrypted);
|
|
160
|
+
return settings;
|
|
161
|
+
});
|
|
162
|
+
return selectSecrets(bundle, paths, profile);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
|
|
165
|
+
let registration = "Public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and reload Configre.";
|
|
166
|
+
try {
|
|
167
|
+
registerRecipient(paths, identity, profile);
|
|
168
|
+
} catch (registrationError) {
|
|
169
|
+
registration = registrationError.code === "CONFIGRE_REGISTRATION_FAILED"
|
|
170
|
+
? registrationError.message
|
|
171
|
+
: "Automatic public-key registration failed; check Git and file permissions, then reload Configre to retry.";
|
|
153
172
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
settings.files[name] = {};
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (hasEncrypted) {
|
|
161
|
-
const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
|
|
162
|
-
if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
|
|
163
|
-
return settings;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
writeEncrypted(paths.encrypted, encrypt(settings, recipients));
|
|
167
|
-
return settings;
|
|
168
|
-
});
|
|
169
|
-
return selectSecrets(bundle, paths, profile);
|
|
173
|
+
log.warn("Secrets not authorized; continuing with public settings only.", registration, "Public key:", identity.publicPath);
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
export default loadSecrets;
|
package/secrets/log.js
ADDED
package/secrets/register.js
CHANGED
|
@@ -2,11 +2,14 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import config from "./config.js";
|
|
5
|
+
import log from "./log.js";
|
|
5
6
|
import { parsePublicKey } from "./crypto.js";
|
|
6
7
|
import { stat, readText, withLock } from "./files.js";
|
|
7
8
|
|
|
8
9
|
function registrationError(message) {
|
|
9
|
-
|
|
10
|
+
const error = new Error(`Configre secrets: not authorized; automatic registration ${message}`);
|
|
11
|
+
error.code = "CONFIGRE_REGISTRATION_FAILED";
|
|
12
|
+
return error;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
function git(directory, args, operation, options = {}) {
|
|
@@ -84,9 +87,15 @@ function registerRecipient(paths, identity, profile) {
|
|
|
84
87
|
git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, relative], "adding the public key to the isolated index", { env });
|
|
85
88
|
const tree = git(root, ["write-tree"], "building the registration tree", { env }).trim();
|
|
86
89
|
const commit = git(root, ["commit-tree", tree, "-p", head, "-m", `Configre: register ${profile}`], "creating the public-key commit (Git author identity must be configured)").trim();
|
|
87
|
-
fs.mkdirSync(directory, { recursive: true })
|
|
88
|
-
|
|
90
|
+
if (fs.mkdirSync(directory, { recursive: true })) {
|
|
91
|
+
log.info("Created recipients directory", directory);
|
|
92
|
+
}
|
|
93
|
+
if (!stat(filename)) {
|
|
94
|
+
fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
|
|
95
|
+
log.info("Created public-key registration file", filename);
|
|
96
|
+
}
|
|
89
97
|
git(root, ["push", "--no-verify", "--no-follow-tags", "--", remote, `${commit}:${remoteBranch}`], "pushing the public-key commit");
|
|
98
|
+
log.info("Published public key to Git", filename);
|
|
90
99
|
if (git(root, ["symbolic-ref", "--quiet", "HEAD"], "checking the current branch").trim() !== branch) {
|
|
91
100
|
throw registrationError("published the public key, but the local branch changed concurrently; synchronize the checkout");
|
|
92
101
|
}
|
package/test/secrets.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import test from "node:test";
|
|
9
9
|
import Configre from "../index.js";
|
|
10
|
+
import log from "../secrets/log.js";
|
|
10
11
|
import { generatePrivateKey, parsePrivateKey, encrypt } from "../secrets/crypto.js";
|
|
11
12
|
|
|
12
13
|
const sentinel = "synthetic-secret-for-configre-tests";
|
|
@@ -17,6 +18,9 @@ test.before(() => {
|
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
function fixture(t, { seed = true } = {}) {
|
|
21
|
+
const logs = { info: [], warn: [] };
|
|
22
|
+
t.mock.method(log, "info", (...args) => logs.info.push(args));
|
|
23
|
+
t.mock.method(log, "warn", (...args) => logs.warn.push(args));
|
|
20
24
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "configre-secrets-"));
|
|
21
25
|
const homes = [0, 1, 2].map(index => path.join(root, `home-${index}`));
|
|
22
26
|
let home = homes[0];
|
|
@@ -36,7 +40,7 @@ function fixture(t, { seed = true } = {}) {
|
|
|
36
40
|
}
|
|
37
41
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
38
42
|
return {
|
|
39
|
-
root, config, homes,
|
|
43
|
+
root, config, homes, logs,
|
|
40
44
|
local: path.join(config, "index.secret.cjs"),
|
|
41
45
|
encrypted: path.join(config, "secrets.enc.json"),
|
|
42
46
|
recipients: path.join(config, "recipients"),
|
|
@@ -51,7 +55,16 @@ function writeSettings(f, settings = { api: { key: sentinel } }) {
|
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
function load(f) {
|
|
54
|
-
return Configre(f.config
|
|
58
|
+
return Configre(f.config);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertPublicOnly(f, logs, warning) {
|
|
62
|
+
const count = logs.warn.length;
|
|
63
|
+
const config = load(f);
|
|
64
|
+
assert.equal(config.api.key, "");
|
|
65
|
+
assert.equal(logs.warn.length, count + 1);
|
|
66
|
+
assert.match(logs.warn.at(-1).join(" "), warning);
|
|
67
|
+
return config;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
function envelope(f) {
|
|
@@ -98,27 +111,88 @@ function gitFixture(t) {
|
|
|
98
111
|
};
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
test("
|
|
114
|
+
test("missing secret counterparts leave configuration and identity untouched", t => {
|
|
102
115
|
const f = fixture(t, { seed: false });
|
|
103
|
-
fs.
|
|
116
|
+
const publicFiles = fs.readdirSync(f.config);
|
|
104
117
|
t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
|
|
105
118
|
assert.equal(Configre(f.config).api.key, "");
|
|
106
|
-
assert.
|
|
119
|
+
assert.deepEqual(fs.readdirSync(f.config), publicFiles);
|
|
120
|
+
fs.writeFileSync(path.join(f.config, "other.secret.cjs"), "invalid-json");
|
|
121
|
+
const files = fs.readdirSync(f.config);
|
|
122
|
+
assert.equal(Configre(f.config).api.key, "");
|
|
107
123
|
assert.equal(new Configre(f.config).get().api.key, "");
|
|
108
|
-
|
|
109
|
-
assert.throws(() => Configre(f.config, options), /options must be an object/);
|
|
110
|
-
}
|
|
111
|
-
assert.equal(fs.existsSync(f.encrypted), false);
|
|
112
|
-
assert.equal(fs.existsSync(path.join(f.config, ".gitignore")), false);
|
|
124
|
+
assert.deepEqual(fs.readdirSync(f.config), files);
|
|
113
125
|
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
126
|
+
assert.deepEqual(f.logs, { info: [], warn: [] });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
|
|
130
|
+
const f = fixture(t);
|
|
131
|
+
writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
|
|
132
|
+
load(f);
|
|
133
|
+
const consumer = consumerCopy(f, "unauthorized-consumer");
|
|
134
|
+
f.useHome(1);
|
|
135
|
+
const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
|
|
136
|
+
assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
|
|
137
|
+
assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
|
|
138
|
+
assert.deepEqual(new Configre(consumer.config).get(), config);
|
|
139
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
140
|
+
const result = spawnSync(process.execPath, ["-e", `
|
|
141
|
+
const assert = require('node:assert/strict');
|
|
142
|
+
const os = require('node:os');
|
|
143
|
+
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
144
|
+
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
145
|
+
const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
|
|
146
|
+
assert.equal(cfg.api.key, '');
|
|
147
|
+
assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
|
|
148
|
+
console.info('Application started');
|
|
149
|
+
`], {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
env: {
|
|
152
|
+
...process.env, DEBUG: "Configre:*",
|
|
153
|
+
CONFIGRE_TEST_HOME: f.homes[1],
|
|
154
|
+
CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
|
|
155
|
+
CONFIGRE_TEST_PATH: consumer.config
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
assert.equal(result.status, 0, result.stderr);
|
|
159
|
+
const output = result.stdout + result.stderr;
|
|
160
|
+
assert.match(output, /Secrets not authorized; continuing with public settings only/);
|
|
161
|
+
assert.match(output, /Application started/);
|
|
162
|
+
assert.equal(output.includes(sentinel), false);
|
|
163
|
+
assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
|
|
164
|
+
assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
|
|
114
165
|
});
|
|
115
166
|
|
|
116
|
-
|
|
167
|
+
for (const profile of ["testhost", "testhost.dev", "forced"]) {
|
|
168
|
+
test(`a ${profile} secret activates secrets without a base secret module`, t => {
|
|
169
|
+
const f = fixture(t);
|
|
170
|
+
if (profile === "forced") {
|
|
171
|
+
const previousArgs = process.argv;
|
|
172
|
+
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=forced"];
|
|
173
|
+
t.after(() => { process.argv = previousArgs; });
|
|
174
|
+
}
|
|
175
|
+
const local = path.join(f.config, profile + ".secret.cjs");
|
|
176
|
+
writeSettings({ local });
|
|
177
|
+
assert.equal(load(f).api.key, sentinel);
|
|
178
|
+
assert.equal(fs.existsSync(f.local), false);
|
|
179
|
+
assert.deepEqual(fs.readdirSync(f.config).filter(name => name.endsWith(".secret.cjs")), [path.basename(local)]);
|
|
180
|
+
assert.equal(load(consumerCopy(f, "profile-only-consumer")).api.key, sentinel);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test("an existing empty secret module initializes secrets and reuses generated files", t => {
|
|
117
185
|
const f = fixture(t, { seed: false });
|
|
186
|
+
fs.writeFileSync(f.local, "module.exports = {};\n", { mode: 0o600 });
|
|
118
187
|
const ignore = path.join(f.config, ".gitignore");
|
|
119
188
|
fs.writeFileSync(ignore, "# existing rules\n*.log");
|
|
120
189
|
const config = load(f);
|
|
190
|
+
const initialLogs = f.logs.info.length;
|
|
121
191
|
assert.deepEqual(config, Configre(f.config));
|
|
192
|
+
assert.equal(f.logs.info.length, initialLogs);
|
|
193
|
+
for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
|
|
194
|
+
assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
|
|
195
|
+
}
|
|
122
196
|
const original = fs.readFileSync(f.privatePath(0));
|
|
123
197
|
const published = fs.readFileSync(f.publicPath(0), "utf8");
|
|
124
198
|
assert.equal(parsePrivateKey(original).publicKey, published);
|
|
@@ -139,6 +213,9 @@ test("first use initializes empty secrets and reuses all generated files", t =>
|
|
|
139
213
|
assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
|
|
140
214
|
writeSettings(f);
|
|
141
215
|
assert.equal(load(f).api.key, sentinel);
|
|
216
|
+
assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
|
|
217
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
218
|
+
assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
|
|
142
219
|
});
|
|
143
220
|
|
|
144
221
|
test("missing administrator files are recreated without losing values or authorized recipients", t => {
|
|
@@ -181,7 +258,7 @@ test("removing every recipient revokes access without restoring keys from old ci
|
|
|
181
258
|
assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
|
|
182
259
|
const consumer = consumerCopy(f, "revoked-consumer");
|
|
183
260
|
f.useHome(1);
|
|
184
|
-
|
|
261
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
185
262
|
});
|
|
186
263
|
|
|
187
264
|
test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
|
|
@@ -198,7 +275,7 @@ test("secrets merge last with profiles, arrays, JSON values and the constructor
|
|
|
198
275
|
assert.deepEqual(config.api, settings.api);
|
|
199
276
|
assert.deepEqual(config.list, [9, 2]);
|
|
200
277
|
assert.deepEqual(config.json, settings.json);
|
|
201
|
-
assert.deepEqual(new Configre(f.config
|
|
278
|
+
assert.deepEqual(new Configre(f.config).get(), config);
|
|
202
279
|
assert.equal(fs.existsSync(f.recipients), true);
|
|
203
280
|
assert.equal(fs.readFileSync(f.encrypted, "utf8").includes(sentinel), false);
|
|
204
281
|
assert.equal(envelope(f).recipients.length, 1);
|
|
@@ -277,19 +354,19 @@ test("base and host secrets select dev and forced profiles identically on consum
|
|
|
277
354
|
assert.deepEqual(fs.readdirSync(consumer.config), consumerFiles);
|
|
278
355
|
});
|
|
279
356
|
|
|
280
|
-
test("administrator
|
|
357
|
+
test("administrator leaves missing counterparts absent and preserves existing host secrets", t => {
|
|
281
358
|
const f = fixture(t);
|
|
282
359
|
writeSettings(f);
|
|
283
360
|
load(f);
|
|
284
361
|
const host = { local: path.join(f.config, "testhost.secret.cjs") };
|
|
285
|
-
assert.equal(fs.
|
|
362
|
+
assert.equal(fs.existsSync(host.local), false);
|
|
286
363
|
writeSettings(host, { api: { key: "host-secret" } });
|
|
287
364
|
const original = fs.readFileSync(host.local);
|
|
288
365
|
fs.writeFileSync(path.join(f.config, "newhost.cjs"), "module.exports = { public: true };\n");
|
|
289
366
|
load(f);
|
|
290
367
|
assert.deepEqual(fs.readFileSync(host.local), original);
|
|
291
|
-
assert.equal(fs.
|
|
292
|
-
const consumer = consumerCopy(f, "
|
|
368
|
+
assert.equal(fs.existsSync(path.join(f.config, "newhost.secret.cjs")), false);
|
|
369
|
+
const consumer = consumerCopy(f, "optional-profile-consumer");
|
|
293
370
|
fs.writeFileSync(path.join(consumer.config, "newhost.cjs"), "module.exports = {};\n");
|
|
294
371
|
assert.equal(load(consumer).api.key, "host-secret");
|
|
295
372
|
assert.equal(fs.existsSync(path.join(consumer.config, "newhost.secret.cjs")), false);
|
|
@@ -320,7 +397,7 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
320
397
|
const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
|
|
321
398
|
assert.deepEqual(after, before);
|
|
322
399
|
f.useHome(2);
|
|
323
|
-
|
|
400
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
324
401
|
f.useHome(0);
|
|
325
402
|
fs.unlinkSync(path.join(f.recipients, "developer.pub"));
|
|
326
403
|
load(f);
|
|
@@ -329,9 +406,9 @@ test("grant and revoke work across isolated machines without consumer project wr
|
|
|
329
406
|
assert.notEqual(revoked.iv, shared.iv);
|
|
330
407
|
fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
|
|
331
408
|
f.useHome(1);
|
|
332
|
-
|
|
409
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
333
410
|
writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
|
|
334
|
-
|
|
411
|
+
assertPublicOnly(consumer, f.logs, /not authorized/);
|
|
335
412
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
|
|
336
413
|
fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
|
|
337
414
|
fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
|
|
@@ -467,20 +544,33 @@ test("private permissions and symlinks are rejected", { skip: process.platform =
|
|
|
467
544
|
assert.throws(() => load(f), /regular file/);
|
|
468
545
|
});
|
|
469
546
|
|
|
547
|
+
test("explicit config files without their own secret sidecar leave identities and files untouched", t => {
|
|
548
|
+
const f = fixture(t, { seed: false });
|
|
549
|
+
const filename = path.join(f.root, "settings.cjs");
|
|
550
|
+
fs.writeFileSync(filename, 'module.exports = { public: true };');
|
|
551
|
+
fs.writeFileSync(path.join(f.root, "other.secret.cjs"), "invalid-json");
|
|
552
|
+
const files = fs.readdirSync(f.root);
|
|
553
|
+
t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
|
|
554
|
+
assert.deepEqual(Configre(filename), { public: true });
|
|
555
|
+
assert.deepEqual(Configre(filename.slice(0, -4)), { public: true });
|
|
556
|
+
assert.deepEqual(fs.readdirSync(f.root), files);
|
|
557
|
+
assert.equal(fs.existsSync(f.homes[0]), false);
|
|
558
|
+
});
|
|
559
|
+
|
|
470
560
|
test("explicit config files and extensionless paths use sidecars beside the resolved file", t => {
|
|
471
561
|
const f = fixture(t);
|
|
472
562
|
const filename = path.join(f.root, "settings.cjs");
|
|
473
563
|
fs.writeFileSync(filename, 'module.exports = { api: { key: "", host: "file" } };');
|
|
474
564
|
const local = filename.slice(0, -4) + ".secret.cjs";
|
|
475
565
|
writeSettings({ local });
|
|
476
|
-
assert.equal(Configre(filename
|
|
477
|
-
assert.equal(Configre(filename.slice(0, -4)
|
|
566
|
+
assert.equal(Configre(filename).api.key, sentinel);
|
|
567
|
+
assert.equal(Configre(filename.slice(0, -4)).api.key, sentinel);
|
|
478
568
|
assert.equal(fs.existsSync(filename + ".secrets.enc.json"), true);
|
|
479
569
|
assert.equal(fs.existsSync(filename + ".recipients"), true);
|
|
480
570
|
assert.equal(fs.existsSync(path.join(f.root, "secrets.enc.json")), false);
|
|
481
571
|
const relative = path.relative(process.cwd(), f.config);
|
|
482
572
|
writeSettings(f);
|
|
483
|
-
assert.equal(Configre(relative
|
|
573
|
+
assert.equal(Configre(relative).api.key, sentinel);
|
|
484
574
|
});
|
|
485
575
|
|
|
486
576
|
test("ESM callers can use the same synchronous API", t => {
|
|
@@ -494,7 +584,7 @@ test("ESM callers can use the same synchronous API", t => {
|
|
|
494
584
|
import os from 'node:os';
|
|
495
585
|
import Configre from ${JSON.stringify(moduleURL)};
|
|
496
586
|
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
497
|
-
const cfg = Configre(process.env.CONFIGRE_TEST_PATH
|
|
587
|
+
const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
|
|
498
588
|
assert.equal(typeof cfg.then, 'undefined');
|
|
499
589
|
assert.equal(cfg.api.key, ${JSON.stringify(sentinel)});
|
|
500
590
|
`);
|
|
@@ -511,7 +601,7 @@ test("symlinked configuration directories keep directory sidecar names", { skip:
|
|
|
511
601
|
writeSettings(f);
|
|
512
602
|
const linked = path.join(f.root, "linked-config");
|
|
513
603
|
fs.symlinkSync(f.config, linked);
|
|
514
|
-
assert.equal(Configre(linked
|
|
604
|
+
assert.equal(Configre(linked).api.key, sentinel);
|
|
515
605
|
assert.equal(fs.existsSync(f.encrypted), true);
|
|
516
606
|
assert.equal(fs.existsSync(path.join(f.config, "index.cjs.secrets.enc.json")), false);
|
|
517
607
|
});
|
|
@@ -520,13 +610,12 @@ test("error output never includes local secret values or private key contents",
|
|
|
520
610
|
const f = fixture(t);
|
|
521
611
|
fs.writeFileSync(f.local, `module.exports = {secret: "${sentinel}", bad`);
|
|
522
612
|
assert.throws(() => load(f), /invalid secret module/);
|
|
523
|
-
assert.equal(new Configre(f.config)._isNested, false);
|
|
524
613
|
const script = `
|
|
525
614
|
const os = require('node:os');
|
|
526
615
|
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
527
616
|
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
528
617
|
try {
|
|
529
|
-
Configre(process.env.CONFIGRE_TEST_PATH
|
|
618
|
+
Configre(process.env.CONFIGRE_TEST_PATH);
|
|
530
619
|
} catch (error) {
|
|
531
620
|
console.error(error.stack);
|
|
532
621
|
process.exitCode = 1;
|
|
@@ -545,6 +634,8 @@ test("error output never includes local secret values or private key contents",
|
|
|
545
634
|
assert.match(result.stderr, /invalid secret module/);
|
|
546
635
|
assert.equal((result.stdout + result.stderr).includes(sentinel), false);
|
|
547
636
|
assert.equal((result.stdout + result.stderr).includes("BEGIN PRIVATE KEY"), false);
|
|
637
|
+
writeSettings(f);
|
|
638
|
+
assert.equal(new Configre(f.config)._isNested, false);
|
|
548
639
|
});
|
|
549
640
|
|
|
550
641
|
test("Git exclusions preserve existing rules and never hide already tracked local files", t => {
|
|
@@ -568,6 +659,7 @@ test("Git exclusions preserve existing rules and never hide already tracked loca
|
|
|
568
659
|
assert.deepEqual(fs.readFileSync(f.encrypted), original);
|
|
569
660
|
assert.ok(git(f.root, ["ls-files"]).includes("config/index.secret.cjs"));
|
|
570
661
|
git(f.root, ["rm", "--cached", "config/index.secret.cjs"]);
|
|
662
|
+
writeSettings({ local: path.join(f.config, "testhost.secret.cjs") }, {});
|
|
571
663
|
git(f.root, ["add", "-f", "config/testhost.secret.cjs"]);
|
|
572
664
|
assert.throws(() => load(f), /tracked by Git/);
|
|
573
665
|
});
|
|
@@ -579,13 +671,13 @@ test("recipient files can be tracked and sidecar names are escaped literally in
|
|
|
579
671
|
fs.writeFileSync(filename, "module.exports = {};");
|
|
580
672
|
const local = filename.slice(0, -4) + ".secret.cjs";
|
|
581
673
|
writeSettings({ local });
|
|
582
|
-
Configre(filename
|
|
674
|
+
Configre(filename);
|
|
583
675
|
assert.ok(git(f.root, ["check-ignore", path.basename(local)]).includes(path.basename(local)));
|
|
584
676
|
const recipient = filename + ".recipients/developer.pub";
|
|
585
677
|
fs.copyFileSync(f.publicPath(1), recipient);
|
|
586
678
|
git(f.root, ["add", "-f", path.relative(f.root, recipient)]);
|
|
587
679
|
const original = fs.readFileSync(filename + ".secrets.enc.json");
|
|
588
|
-
assert.equal(Configre(filename
|
|
680
|
+
assert.equal(Configre(filename).api.key, sentinel);
|
|
589
681
|
assert.notDeepEqual(fs.readFileSync(filename + ".secrets.enc.json"), original);
|
|
590
682
|
});
|
|
591
683
|
|
|
@@ -601,7 +693,11 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
601
693
|
fs.writeFileSync(untracked, sentinel);
|
|
602
694
|
const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
|
|
603
695
|
f.useHome(1);
|
|
604
|
-
|
|
696
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
697
|
+
const registeredPath = fs.realpathSync(f.registration);
|
|
698
|
+
assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
|
|
699
|
+
assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
|
|
700
|
+
const registrationLogs = f.logs.info.length;
|
|
605
701
|
const published = git(f.remote, ["rev-parse", "main"]).trim();
|
|
606
702
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
|
|
607
703
|
assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
|
|
@@ -613,7 +709,8 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
613
709
|
assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
|
|
614
710
|
assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
|
|
615
711
|
assert.equal(git(f.remote, ["tag", "--list"]), "");
|
|
616
|
-
|
|
712
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
713
|
+
assert.equal(f.logs.info.length, registrationLogs);
|
|
617
714
|
assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
|
|
618
715
|
|
|
619
716
|
f.useHome(0);
|
|
@@ -625,7 +722,9 @@ test("a server publishes only its public key once and receives automatic authori
|
|
|
625
722
|
git(f.root, ["push", "--quiet"]);
|
|
626
723
|
git(f.checkout, ["pull", "--quiet", "--ff-only"]);
|
|
627
724
|
f.useHome(1);
|
|
725
|
+
const warnings = f.logs.warn.length;
|
|
628
726
|
assert.equal(load(f.consumer).api.key, sentinel);
|
|
727
|
+
assert.equal(f.logs.warn.length, warnings);
|
|
629
728
|
f.useHome(0);
|
|
630
729
|
writeSettings(f, { api: { key: sentinel + "-updated" } });
|
|
631
730
|
load(f);
|
|
@@ -647,18 +746,16 @@ test("a rejected registration push preserves the branch and staging area and can
|
|
|
647
746
|
const head = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
648
747
|
const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
|
|
649
748
|
f.useHome(1);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
return true;
|
|
654
|
-
});
|
|
749
|
+
assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
|
|
750
|
+
assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
|
|
751
|
+
assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
|
|
655
752
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
|
|
656
753
|
assert.equal(git(f.remote, ["rev-parse", "main"]), head);
|
|
657
754
|
assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
|
|
658
755
|
assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
|
|
659
756
|
assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
|
|
660
757
|
fs.unlinkSync(hook);
|
|
661
|
-
|
|
758
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
662
759
|
assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
|
|
663
760
|
});
|
|
664
761
|
|
|
@@ -674,9 +771,9 @@ test("registration recovers when publication succeeded before the local branch a
|
|
|
674
771
|
git(interrupted, ["add", "tracked.txt"]);
|
|
675
772
|
fs.writeFileSync(tracked, "unstaged work\n");
|
|
676
773
|
f.useHome(1);
|
|
677
|
-
|
|
774
|
+
assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
|
|
678
775
|
const published = git(f.remote, ["rev-parse", "main"]);
|
|
679
|
-
|
|
776
|
+
assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
|
|
680
777
|
git(interrupted, ["pull", "--quiet", "--ff-only"]);
|
|
681
778
|
assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
|
|
682
779
|
assert.equal(git(f.remote, ["rev-parse", "main"]), published);
|
|
@@ -689,16 +786,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
|
|
|
689
786
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
690
787
|
git(f.checkout, ["checkout", "--quiet", "--detach"]);
|
|
691
788
|
f.useHome(1);
|
|
692
|
-
|
|
789
|
+
assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
|
|
693
790
|
git(f.checkout, ["checkout", "--quiet", "main"]);
|
|
694
791
|
git(f.checkout, ["branch", "--unset-upstream"]);
|
|
695
|
-
|
|
792
|
+
assertPublicOnly(f.consumer, f.logs, /upstream remote/);
|
|
696
793
|
git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
|
|
697
794
|
fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
|
|
698
795
|
git(f.checkout, ["add", "tracked.txt"]);
|
|
699
796
|
git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
|
|
700
797
|
const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
|
|
701
|
-
|
|
798
|
+
assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
|
|
702
799
|
assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
|
|
703
800
|
assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
|
|
704
801
|
assert.equal(fs.existsSync(f.registration), false);
|
|
@@ -710,11 +807,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
710
807
|
const previousArgs = process.argv;
|
|
711
808
|
process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
|
|
712
809
|
t.after(() => { process.argv = previousArgs; });
|
|
713
|
-
|
|
810
|
+
assertPublicOnly(f.consumer, f.logs, /requires a profile/);
|
|
714
811
|
process.argv = previousArgs;
|
|
715
812
|
fs.mkdirSync(path.dirname(f.registration));
|
|
716
813
|
fs.copyFileSync(f.publicPath(2), f.registration);
|
|
717
|
-
|
|
814
|
+
assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
|
|
718
815
|
assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
|
|
719
816
|
fs.unlinkSync(f.registration);
|
|
720
817
|
fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
|
|
@@ -722,7 +819,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
|
|
|
722
819
|
git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
|
|
723
820
|
git(f.root, ["push", "--quiet"]);
|
|
724
821
|
const remoteHead = git(f.remote, ["rev-parse", "main"]);
|
|
725
|
-
|
|
822
|
+
assertPublicOnly(f.consumer, f.logs, /different published key/);
|
|
726
823
|
const damaged = envelope(f);
|
|
727
824
|
damaged.extra = sentinel;
|
|
728
825
|
fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));
|
|
@@ -773,7 +870,7 @@ test("a second process cannot write while an encrypted replacement is pending",
|
|
|
773
870
|
const os = require('node:os');
|
|
774
871
|
os.homedir = () => process.env.CONFIGRE_TEST_HOME;
|
|
775
872
|
const Configre = require(process.env.CONFIGRE_TEST_MODULE);
|
|
776
|
-
assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH
|
|
873
|
+
assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH), /another writer/);
|
|
777
874
|
`], {
|
|
778
875
|
encoding: "utf8",
|
|
779
876
|
env: {
|
|
@@ -792,6 +889,5 @@ test("a second process cannot write while an encrypted replacement is pending",
|
|
|
792
889
|
assert.equal(load(f).api.key, sentinel + "-new");
|
|
793
890
|
assert.equal(contested, true);
|
|
794
891
|
fs.unlinkSync(f.local);
|
|
795
|
-
fs.unlinkSync(path.join(f.config, "testhost.secret.cjs"));
|
|
796
892
|
assert.equal(load(f).api.key, sentinel + "-new");
|
|
797
893
|
});
|