@red-hat-developer-hub/e2e-test-utils 2.1.15 → 2.1.17
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/dist/deployment/rhdh/config/common/dynamic-plugins.yaml +5 -0
- package/dist/deployment/rhdh/constants.d.ts +0 -5
- package/dist/deployment/rhdh/constants.d.ts.map +1 -1
- package/dist/deployment/rhdh/constants.js +0 -5
- package/dist/deployment/rhdh/deployment.d.ts +2 -2
- package/dist/deployment/rhdh/deployment.d.ts.map +1 -1
- package/dist/deployment/rhdh/deployment.js +9 -25
- package/dist/deployment/rhdh/deployment.test.js +9 -9
- package/dist/deployment/rhdh/types.d.ts +0 -4
- package/dist/deployment/rhdh/types.d.ts.map +1 -1
- package/dist/playwright/helpers/github-session.test.js +1 -1
- package/dist/secrets/bitwarden.d.ts +43 -0
- package/dist/secrets/bitwarden.d.ts.map +1 -1
- package/dist/secrets/bitwarden.js +465 -53
- package/dist/secrets/cli.d.ts +48 -1
- package/dist/secrets/cli.d.ts.map +1 -1
- package/dist/secrets/cli.js +455 -28
- package/dist/secrets/command.d.ts +7 -0
- package/dist/secrets/command.d.ts.map +1 -1
- package/dist/secrets/command.js +103 -26
- package/dist/secrets/config.d.ts +11 -1
- package/dist/secrets/config.d.ts.map +1 -1
- package/dist/secrets/config.js +32 -1
- package/dist/secrets/environment-name.d.ts +2 -0
- package/dist/secrets/environment-name.d.ts.map +1 -0
- package/dist/secrets/environment-name.js +4 -0
- package/dist/secrets/environment.d.ts +10 -0
- package/dist/secrets/environment.d.ts.map +1 -1
- package/dist/secrets/environment.js +35 -4
- package/dist/secrets/exec.d.ts +9 -1
- package/dist/secrets/exec.d.ts.map +1 -1
- package/dist/secrets/exec.js +318 -38
- package/dist/secrets/gsm-wrapper.d.ts +42 -0
- package/dist/secrets/gsm-wrapper.d.ts.map +1 -0
- package/dist/secrets/gsm-wrapper.js +238 -0
- package/dist/secrets/gsm.d.ts +33 -0
- package/dist/secrets/gsm.d.ts.map +1 -0
- package/dist/secrets/gsm.js +172 -0
- package/dist/secrets/index.d.ts +9 -4
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +7 -2
- package/dist/secrets/lock.d.ts +3 -0
- package/dist/secrets/lock.d.ts.map +1 -0
- package/dist/secrets/lock.js +28 -0
- package/dist/secrets/mutation.d.ts +71 -0
- package/dist/secrets/mutation.d.ts.map +1 -0
- package/dist/secrets/mutation.js +210 -0
- package/dist/secrets/secret-input.d.ts +17 -0
- package/dist/secrets/secret-input.d.ts.map +1 -0
- package/dist/secrets/secret-input.js +40 -0
- package/dist/secrets/stream.d.ts +9 -0
- package/dist/secrets/stream.d.ts.map +1 -0
- package/dist/secrets/stream.js +211 -0
- package/dist/secrets/temporary-secret.d.ts +13 -0
- package/dist/secrets/temporary-secret.d.ts.map +1 -0
- package/dist/secrets/temporary-secret.js +87 -0
- package/dist/secrets/tests/bitwarden.test.js +640 -0
- package/dist/secrets/tests/cli.integration.test.js +131 -3
- package/dist/secrets/tests/cli.test.js +281 -6
- package/dist/secrets/tests/command.test.d.ts +2 -0
- package/dist/secrets/tests/command.test.d.ts.map +1 -0
- package/dist/secrets/tests/command.test.js +39 -0
- package/dist/secrets/tests/config.test.js +20 -1
- package/dist/secrets/tests/environment.test.js +145 -3
- package/dist/secrets/tests/exec.test.js +469 -2
- package/dist/secrets/tests/gsm-wrapper.test.d.ts +2 -0
- package/dist/secrets/tests/gsm-wrapper.test.d.ts.map +1 -0
- package/dist/secrets/tests/gsm-wrapper.test.js +166 -0
- package/dist/secrets/tests/gsm.test.d.ts +2 -0
- package/dist/secrets/tests/gsm.test.d.ts.map +1 -0
- package/dist/secrets/tests/gsm.test.js +181 -0
- package/dist/secrets/tests/mutation.test.d.ts +2 -0
- package/dist/secrets/tests/mutation.test.d.ts.map +1 -0
- package/dist/secrets/tests/mutation.test.js +390 -0
- package/dist/secrets/tests/secret-input.test.d.ts +2 -0
- package/dist/secrets/tests/secret-input.test.d.ts.map +1 -0
- package/dist/secrets/tests/secret-input.test.js +71 -0
- package/dist/secrets/tests/stream.test.d.ts +2 -0
- package/dist/secrets/tests/stream.test.d.ts.map +1 -0
- package/dist/secrets/tests/stream.test.js +88 -0
- package/dist/secrets/tests/temporary-secret.test.d.ts +2 -0
- package/dist/secrets/tests/temporary-secret.test.d.ts.map +1 -0
- package/dist/secrets/tests/temporary-secret.test.js +76 -0
- package/dist/utils/plugin-metadata.d.ts +5 -0
- package/dist/utils/plugin-metadata.d.ts.map +1 -1
- package/dist/utils/plugin-metadata.js +19 -0
- package/dist/utils/tests/plugin-metadata.test.js +17 -1
- package/dist/utils/workspace-paths.d.ts +0 -2
- package/dist/utils/workspace-paths.d.ts.map +1 -1
- package/dist/utils/workspace-paths.js +0 -4
- package/package.json +1 -1
- package/dist/deployment/rhdh/config/new-frontend-system/dynamic-plugins.yaml +0 -5
- package/dist/deployment/rhdh/config/new-frontend-system/value_file.yaml +0 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixtures model process environment keys */
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import test from "node:test";
|
|
4
|
-
import { materializeEnvironment, } from "../environment.js";
|
|
4
|
+
import { materializeEnvironmentWithSecrets, materializeEnvironment, materializeStreamEnvironment, } from "../environment.js";
|
|
5
|
+
import { SECRET_STREAM_ENVIRONMENT_VARIABLE, SECRET_STREAM_FD, } from "../stream.js";
|
|
5
6
|
const selector = {
|
|
6
7
|
prefix: "global/",
|
|
7
8
|
optional: false,
|
|
@@ -57,6 +58,109 @@ test("maps selected notes into a child environment without mutating the parent",
|
|
|
57
58
|
VAULT: "legacy-provider-setting",
|
|
58
59
|
});
|
|
59
60
|
});
|
|
61
|
+
test("returns sorted mapped secret entries", () => {
|
|
62
|
+
const identitySelector = {
|
|
63
|
+
...selector,
|
|
64
|
+
destination: {
|
|
65
|
+
...selector.destination,
|
|
66
|
+
requirePrefix: undefined,
|
|
67
|
+
keyTransform: "identity",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const result = materializeEnvironmentWithSecrets([
|
|
71
|
+
{
|
|
72
|
+
id: "zeta-id",
|
|
73
|
+
name: "global/ZETA",
|
|
74
|
+
value: "zeta-value",
|
|
75
|
+
selector: identitySelector,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "alpha-id",
|
|
79
|
+
name: "global/ALPHA",
|
|
80
|
+
value: "alpha-value",
|
|
81
|
+
selector: identitySelector,
|
|
82
|
+
},
|
|
83
|
+
], [identitySelector], {});
|
|
84
|
+
assert.deepEqual(result.secrets, [
|
|
85
|
+
{ name: "ALPHA", value: "alpha-value" },
|
|
86
|
+
{ name: "ZETA", value: "zeta-value" },
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
test("does not retain an inherited stream marker outside stream mode", () => {
|
|
90
|
+
const secrets = [
|
|
91
|
+
{
|
|
92
|
+
id: "token-id",
|
|
93
|
+
name: "global/VAULT_TOKEN",
|
|
94
|
+
value: "synthetic-value",
|
|
95
|
+
selector,
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
assert.deepEqual(materializeEnvironment(secrets, [selector], {
|
|
99
|
+
PATH: "/bin",
|
|
100
|
+
RHDH_E2E_SECRET_FD: "inherited-fd",
|
|
101
|
+
}), {
|
|
102
|
+
PATH: "/bin",
|
|
103
|
+
VAULT_TOKEN: "synthetic-value",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
test("removes selected names from a stream child environment", () => {
|
|
107
|
+
const parent = {
|
|
108
|
+
PATH: "/bin",
|
|
109
|
+
VAULT_TOKEN: "inherited-value",
|
|
110
|
+
RHDH_E2E_SECRET_FD: "stale-fd",
|
|
111
|
+
};
|
|
112
|
+
const materialized = materializeEnvironmentWithSecrets([
|
|
113
|
+
{
|
|
114
|
+
id: "token-id",
|
|
115
|
+
name: "global/VAULT_TOKEN",
|
|
116
|
+
value: "stream-value",
|
|
117
|
+
selector,
|
|
118
|
+
},
|
|
119
|
+
], [selector], parent);
|
|
120
|
+
const streamEnvironment = materializeStreamEnvironment(materialized);
|
|
121
|
+
assert.equal(streamEnvironment.VAULT_TOKEN, undefined);
|
|
122
|
+
assert.equal(streamEnvironment[SECRET_STREAM_ENVIRONMENT_VARIABLE], String(SECRET_STREAM_FD));
|
|
123
|
+
assert.equal(streamEnvironment.PATH, "/bin");
|
|
124
|
+
assert.equal(parent.VAULT_TOKEN, "inherited-value");
|
|
125
|
+
assert.equal(parent.RHDH_E2E_SECRET_FD, "stale-fd");
|
|
126
|
+
});
|
|
127
|
+
test("does not mutate the parent process environment", () => {
|
|
128
|
+
const parent = {
|
|
129
|
+
RHDH_E2E_SECRET_FD: "stale-fd",
|
|
130
|
+
VAULT_TOKEN: "old-value",
|
|
131
|
+
};
|
|
132
|
+
const materialized = materializeEnvironmentWithSecrets([
|
|
133
|
+
{
|
|
134
|
+
id: "token-id",
|
|
135
|
+
name: "global/VAULT_TOKEN",
|
|
136
|
+
value: "new-value",
|
|
137
|
+
selector,
|
|
138
|
+
},
|
|
139
|
+
], [selector], parent);
|
|
140
|
+
materializeStreamEnvironment(materialized);
|
|
141
|
+
assert.deepEqual(parent, {
|
|
142
|
+
RHDH_E2E_SECRET_FD: "stale-fd",
|
|
143
|
+
VAULT_TOKEN: "old-value",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
test("reserves RHDH_E2E_SECRET_FD", () => {
|
|
147
|
+
const identitySelector = {
|
|
148
|
+
...selector,
|
|
149
|
+
destination: {
|
|
150
|
+
...selector.destination,
|
|
151
|
+
requirePrefix: undefined,
|
|
152
|
+
keyTransform: "identity",
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
assert.throws(() => materializeEnvironmentWithSecrets([
|
|
156
|
+
{
|
|
157
|
+
id: "reserved-fd-id",
|
|
158
|
+
name: "global/RHDH_E2E_SECRET_FD",
|
|
159
|
+
value: "synthetic",
|
|
160
|
+
selector: identitySelector,
|
|
161
|
+
},
|
|
162
|
+
], [identitySelector], {}), /reserved environment variable name.*RHDH_E2E_SECRET_FD/i);
|
|
163
|
+
});
|
|
60
164
|
test("filters item names that do not satisfy requirePrefix", () => {
|
|
61
165
|
const secrets = [
|
|
62
166
|
{
|
|
@@ -84,7 +188,45 @@ test("rejects transformed environment-name collisions", () => {
|
|
|
84
188
|
selector,
|
|
85
189
|
},
|
|
86
190
|
];
|
|
87
|
-
assert.throws(() =>
|
|
191
|
+
assert.throws(() => materializeEnvironmentWithSecrets(secrets, [selector], {}), /environment variable collision.*VAULT_A_B/i);
|
|
192
|
+
});
|
|
193
|
+
test("rejects selected Bitwarden provider variables", () => {
|
|
194
|
+
const identitySelector = {
|
|
195
|
+
...selector,
|
|
196
|
+
destination: {
|
|
197
|
+
...selector.destination,
|
|
198
|
+
requirePrefix: undefined,
|
|
199
|
+
keyTransform: "identity",
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
assert.throws(() => materializeEnvironmentWithSecrets([
|
|
203
|
+
{
|
|
204
|
+
id: "provider-id",
|
|
205
|
+
name: "global/BW_SESSION",
|
|
206
|
+
value: "synthetic",
|
|
207
|
+
selector: identitySelector,
|
|
208
|
+
},
|
|
209
|
+
], [identitySelector], {}), /Bitwarden provider environment variable.*BW_SESSION/i);
|
|
210
|
+
});
|
|
211
|
+
test("materializes prototype-named environment variables as own properties", () => {
|
|
212
|
+
const identitySelector = {
|
|
213
|
+
...selector,
|
|
214
|
+
destination: {
|
|
215
|
+
...selector.destination,
|
|
216
|
+
requirePrefix: undefined,
|
|
217
|
+
keyTransform: "identity",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
const child = materializeEnvironment([
|
|
221
|
+
{
|
|
222
|
+
id: "prototype-id",
|
|
223
|
+
name: "global/__proto__",
|
|
224
|
+
value: "synthetic",
|
|
225
|
+
selector: identitySelector,
|
|
226
|
+
},
|
|
227
|
+
], [identitySelector], {});
|
|
228
|
+
assert.equal(Object.hasOwn(child, "__proto__"), true);
|
|
229
|
+
assert.equal(child.__proto__, "synthetic");
|
|
88
230
|
});
|
|
89
231
|
test("rejects invalid identity environment names", () => {
|
|
90
232
|
const identitySelector = {
|
|
@@ -95,7 +237,7 @@ test("rejects invalid identity environment names", () => {
|
|
|
95
237
|
keyTransform: "identity",
|
|
96
238
|
},
|
|
97
239
|
};
|
|
98
|
-
assert.throws(() =>
|
|
240
|
+
assert.throws(() => materializeEnvironmentWithSecrets([
|
|
99
241
|
{
|
|
100
242
|
id: "invalid-id",
|
|
101
243
|
name: "global/NOT-VALID",
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect -- node:test fixtures model process environment keys */
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention, playwright/expect-expect, playwright/no-conditional-in-test -- node:test fixtures model process environment keys */
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Writable } from "node:stream";
|
|
3
10
|
import test from "node:test";
|
|
4
|
-
import {
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { createChildRunner, executeCommand, runChild, } from "../exec.js";
|
|
5
14
|
const profile = {
|
|
6
15
|
schemaVersion: 1,
|
|
7
16
|
collection: "rhdh-plugin-export-overlays",
|
|
@@ -41,6 +50,7 @@ test("executes a command with only selected secrets in its child environment", a
|
|
|
41
50
|
env: {
|
|
42
51
|
PATH: "/usr/bin",
|
|
43
52
|
BW_SESSION: "synthetic-session",
|
|
53
|
+
RHDH_E2E_SECRET_FD: "stale-fd",
|
|
44
54
|
EXISTING_VALUE: "preserved",
|
|
45
55
|
},
|
|
46
56
|
client,
|
|
@@ -52,6 +62,411 @@ test("executes a command with only selected secrets in its child environment", a
|
|
|
52
62
|
assert.equal(received?.env.VAULT_TOKEN, "synthetic-value");
|
|
53
63
|
assert.equal(received?.env.EXISTING_VALUE, "preserved");
|
|
54
64
|
assert.equal(received?.env.BW_SESSION, undefined);
|
|
65
|
+
assert.equal(received?.env.RHDH_E2E_SECRET_FD, undefined);
|
|
66
|
+
});
|
|
67
|
+
test("does not pass stream options to a non-stream injected runner", async () => {
|
|
68
|
+
let receivedArgumentCount = 0;
|
|
69
|
+
const childRunner = async (...args) => {
|
|
70
|
+
receivedArgumentCount = args.length;
|
|
71
|
+
return 0;
|
|
72
|
+
};
|
|
73
|
+
const exitCode = await executeCommand({
|
|
74
|
+
profile,
|
|
75
|
+
workspaces: [],
|
|
76
|
+
command: "playwright",
|
|
77
|
+
args: [],
|
|
78
|
+
env: { BW_SESSION: "synthetic-session" },
|
|
79
|
+
client: { read: async () => [secret] },
|
|
80
|
+
childRunner,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(exitCode, 0);
|
|
83
|
+
assert.equal(receivedArgumentCount, 3);
|
|
84
|
+
});
|
|
85
|
+
test("streams selected secrets without placing values in the child environment", async () => {
|
|
86
|
+
let childEnvironment;
|
|
87
|
+
let runnerOptions;
|
|
88
|
+
const childRunner = async (_command, _args, env, options) => {
|
|
89
|
+
childEnvironment = env;
|
|
90
|
+
runnerOptions = options;
|
|
91
|
+
return 0;
|
|
92
|
+
};
|
|
93
|
+
const exitCode = await executeCommand({
|
|
94
|
+
profile,
|
|
95
|
+
workspaces: [],
|
|
96
|
+
command: "podman",
|
|
97
|
+
args: ["run"],
|
|
98
|
+
streamSecrets: true,
|
|
99
|
+
env: {
|
|
100
|
+
BW_SESSION: "synthetic-session",
|
|
101
|
+
BW_CLIENTID: "synthetic-client-id",
|
|
102
|
+
VAULT_TOKEN: "stale-value",
|
|
103
|
+
RHDH_E2E_SECRET_FD: "stale-fd",
|
|
104
|
+
},
|
|
105
|
+
client: { read: async () => [secret] },
|
|
106
|
+
childRunner,
|
|
107
|
+
});
|
|
108
|
+
assert.equal(exitCode, 0);
|
|
109
|
+
assert.equal(childEnvironment?.VAULT_TOKEN, undefined);
|
|
110
|
+
assert.equal(childEnvironment?.BW_SESSION, undefined);
|
|
111
|
+
assert.equal(childEnvironment?.BW_CLIENTID, undefined);
|
|
112
|
+
assert.equal(childEnvironment?.RHDH_E2E_SECRET_FD, "3");
|
|
113
|
+
assert.deepEqual(runnerOptions, {
|
|
114
|
+
secretStream: [{ name: "VAULT_TOKEN", value: "synthetic-value" }],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
test("passes sorted mapped entries to the stream child runner", async () => {
|
|
118
|
+
const expandedSelector = secret.selector;
|
|
119
|
+
const secrets = [
|
|
120
|
+
{
|
|
121
|
+
id: "zeta-id",
|
|
122
|
+
name: "global/VAULT_ZETA",
|
|
123
|
+
value: "zeta-secret-value",
|
|
124
|
+
selector: expandedSelector,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "alpha-id",
|
|
128
|
+
name: "global/VAULT_ALPHA",
|
|
129
|
+
value: "alpha-secret-value",
|
|
130
|
+
selector: expandedSelector,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
let runnerOptions;
|
|
134
|
+
const exitCode = await executeCommand({
|
|
135
|
+
profile,
|
|
136
|
+
workspaces: [],
|
|
137
|
+
command: "playwright",
|
|
138
|
+
args: [],
|
|
139
|
+
streamSecrets: true,
|
|
140
|
+
env: { BW_SESSION: "synthetic-session" },
|
|
141
|
+
client: { read: async () => secrets },
|
|
142
|
+
childRunner: async (_command, _args, _env, options) => {
|
|
143
|
+
runnerOptions = options;
|
|
144
|
+
return 0;
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(exitCode, 0);
|
|
148
|
+
assert.deepEqual(runnerOptions?.secretStream, [
|
|
149
|
+
{ name: "VAULT_ALPHA", value: "alpha-secret-value" },
|
|
150
|
+
{ name: "VAULT_ZETA", value: "zeta-secret-value" },
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
test("writes the secret stream to child file descriptor three", async () => {
|
|
154
|
+
const childScript = [
|
|
155
|
+
"const fs = require('node:fs');",
|
|
156
|
+
"const stream = fs.readFileSync(3);",
|
|
157
|
+
"if (!stream.subarray(0, 8).equals(Buffer.from('RHDHSEC1')) || !stream.includes(Buffer.from('RHDHEND1')) || stream.length < 160000) process.exit(1);",
|
|
158
|
+
].join(" ");
|
|
159
|
+
const exitCode = await runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, { secretStream: [{ name: "LARGE_VALUE", value: "x".repeat(160 * 1024) }] });
|
|
160
|
+
assert.equal(exitCode, 0);
|
|
161
|
+
});
|
|
162
|
+
test("decodes multiline, empty, and large values without exposing them to the child environment", async () => {
|
|
163
|
+
const streamModuleUrl = pathToFileURL(path.join(process.cwd(), "dist/secrets/stream.js")).href;
|
|
164
|
+
const childScript = [
|
|
165
|
+
"import fs from 'node:fs';",
|
|
166
|
+
`import { decodeSecretStream } from ${JSON.stringify(streamModuleUrl)};`,
|
|
167
|
+
"const entries = decodeSecretStream(fs.readFileSync(3));",
|
|
168
|
+
"const values = new Map(entries.map(({ name, value }) => [name, value]));",
|
|
169
|
+
"if (process.env.RHDH_E2E_SECRET_FD !== '3') process.exit(2);",
|
|
170
|
+
"if (values.get('VAULT_EMPTY_VALUE') !== '') process.exit(3);",
|
|
171
|
+
"if (values.get('VAULT_MULTILINE_VALUE')?.split('\\n').length !== 3) process.exit(4);",
|
|
172
|
+
"if (values.get('VAULT_LARGE_VALUE')?.length !== 160 * 1024) process.exit(5);",
|
|
173
|
+
"if (process.env.VAULT_EMPTY_VALUE !== undefined || process.env.VAULT_MULTILINE_VALUE !== undefined || process.env.VAULT_LARGE_VALUE !== undefined) process.exit(6);",
|
|
174
|
+
"if (process.env.BW_SESSION !== undefined || process.env.BW_CLIENTID !== undefined) process.exit(7);",
|
|
175
|
+
"try { fs.fstatSync(0); fs.fstatSync(3); } catch { process.exit(9); }",
|
|
176
|
+
"if (process.stdin.fd !== 0 || 0 === 3) process.exit(10);",
|
|
177
|
+
].join(" ");
|
|
178
|
+
const selector = secret.selector;
|
|
179
|
+
const exitCode = await executeCommand({
|
|
180
|
+
profile,
|
|
181
|
+
workspaces: [],
|
|
182
|
+
command: process.execPath,
|
|
183
|
+
args: ["--input-type=module", "-e", childScript],
|
|
184
|
+
streamSecrets: true,
|
|
185
|
+
env: {
|
|
186
|
+
...process.env,
|
|
187
|
+
VAULT_EMPTY_VALUE: "stale-value",
|
|
188
|
+
VAULT_MULTILINE_VALUE: "stale-value",
|
|
189
|
+
VAULT_LARGE_VALUE: "stale-value",
|
|
190
|
+
BW_SESSION: "synthetic-session",
|
|
191
|
+
BW_CLIENTID: "synthetic-client-id",
|
|
192
|
+
},
|
|
193
|
+
client: {
|
|
194
|
+
read: async () => [
|
|
195
|
+
{
|
|
196
|
+
id: "empty-id",
|
|
197
|
+
name: "global/VAULT_EMPTY_VALUE",
|
|
198
|
+
value: "",
|
|
199
|
+
selector,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "multiline-id",
|
|
203
|
+
name: "global/VAULT_MULTILINE_VALUE",
|
|
204
|
+
value: "first line\nsecond line\n",
|
|
205
|
+
selector,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "large-id",
|
|
209
|
+
name: "global/VAULT_LARGE_VALUE",
|
|
210
|
+
value: "x".repeat(160 * 1024),
|
|
211
|
+
selector,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
assert.equal(exitCode, 0);
|
|
217
|
+
});
|
|
218
|
+
test("preserves a stream child's exit code after it reads the stream", async () => {
|
|
219
|
+
const childScript = [
|
|
220
|
+
"const fs = require('node:fs');",
|
|
221
|
+
"fs.readFileSync(3);",
|
|
222
|
+
"process.exit(17);",
|
|
223
|
+
].join(" ");
|
|
224
|
+
const exitCode = await runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, { secretStream: [{ name: "EXIT_VALUE", value: "synthetic-value" }] });
|
|
225
|
+
assert.equal(exitCode, 17);
|
|
226
|
+
});
|
|
227
|
+
test("maps a stream child's SIGTERM to status 143", async () => {
|
|
228
|
+
const childScript = [
|
|
229
|
+
"const fs = require('node:fs');",
|
|
230
|
+
"fs.readFileSync(3);",
|
|
231
|
+
"process.kill(process.pid, 'SIGTERM');",
|
|
232
|
+
].join(" ");
|
|
233
|
+
const exitCode = await runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, { secretStream: [{ name: "SIGNAL_VALUE", value: "synthetic-value" }] });
|
|
234
|
+
assert.equal(exitCode, 143);
|
|
235
|
+
});
|
|
236
|
+
test("preserves the child result when it closes the stream before reading it", async () => {
|
|
237
|
+
const childScript = [
|
|
238
|
+
"const fs = require('node:fs');",
|
|
239
|
+
"fs.closeSync(3);",
|
|
240
|
+
"setTimeout(() => process.exit(17), 50);",
|
|
241
|
+
].join(" ");
|
|
242
|
+
const exitCode = await runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, {
|
|
243
|
+
secretStream: [
|
|
244
|
+
{ name: "EARLY_CLOSE_VALUE", value: "x".repeat(160 * 1024) },
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
assert.equal(exitCode, 17);
|
|
248
|
+
});
|
|
249
|
+
test("settles a backpressured writer when the child closes FD 3 without an error", async () => {
|
|
250
|
+
const childScript = [
|
|
251
|
+
"const fs = require('node:fs');",
|
|
252
|
+
"fs.closeSync(3);",
|
|
253
|
+
"setTimeout(() => process.exit(17), 100);",
|
|
254
|
+
].join(" ");
|
|
255
|
+
const exitCode = await runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, {
|
|
256
|
+
secretStream: [
|
|
257
|
+
{ name: "BACKPRESSURED_VALUE", value: "x".repeat(160 * 1024) },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
assert.equal(exitCode, 17);
|
|
261
|
+
});
|
|
262
|
+
test("maps a forwarded SIGTERM while the stream writer is backpressured", async () => {
|
|
263
|
+
if (process.platform === "win32")
|
|
264
|
+
return;
|
|
265
|
+
const childScript = "setTimeout(() => {}, 10000);";
|
|
266
|
+
const result = runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, {
|
|
267
|
+
secretStream: [
|
|
268
|
+
{ name: "SIGNAL_BACKPRESSURED_VALUE", value: "x".repeat(160 * 1024) },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
await delay(20);
|
|
272
|
+
process.kill(process.pid, "SIGTERM");
|
|
273
|
+
assert.equal(await result, 143);
|
|
274
|
+
});
|
|
275
|
+
test("does not wait for an unstarted writer after a pre-spawn SIGTERM", async () => {
|
|
276
|
+
if (process.platform === "win32")
|
|
277
|
+
return;
|
|
278
|
+
const originalOn = process.on;
|
|
279
|
+
process.on = ((event, listener) => {
|
|
280
|
+
const result = originalOn.call(process, event, listener);
|
|
281
|
+
if (event === "SIGTERM")
|
|
282
|
+
listener();
|
|
283
|
+
return result;
|
|
284
|
+
});
|
|
285
|
+
try {
|
|
286
|
+
const result = runChild(process.execPath, ["-e", "setTimeout(() => {}, 10000);"], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, {
|
|
287
|
+
secretStream: [
|
|
288
|
+
{ name: "PRE_SPAWN_SIGNAL_VALUE", value: "x".repeat(160 * 1024) },
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
assert.equal(await Promise.race([
|
|
292
|
+
result,
|
|
293
|
+
delay(500).then(() => {
|
|
294
|
+
throw new Error("pre-spawn signal left runChild pending");
|
|
295
|
+
}),
|
|
296
|
+
]), 143);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
process.on = originalOn;
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
test("waits for child termination before rejecting a fatal stream writer error", async () => {
|
|
303
|
+
if (process.platform === "win32")
|
|
304
|
+
return;
|
|
305
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "rhdh-e2e-exec-stream-error-test-"));
|
|
306
|
+
const marker = path.join(directory, "state");
|
|
307
|
+
const ready = path.join(directory, "ready");
|
|
308
|
+
let valueReads = 0;
|
|
309
|
+
const entry = {
|
|
310
|
+
name: "FATAL_STREAM_VALUE",
|
|
311
|
+
get value() {
|
|
312
|
+
valueReads++;
|
|
313
|
+
if (valueReads > 4) {
|
|
314
|
+
for (let attempt = 0; attempt < 100 && !existsSync(ready); attempt++) {
|
|
315
|
+
execFileSync("/bin/sh", ["-c", "sleep 0.01"], { stdio: "ignore" });
|
|
316
|
+
}
|
|
317
|
+
throw Object.assign(new Error("synthetic writer failure"), {
|
|
318
|
+
code: "EIO",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return "synthetic-value";
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
const childScript = [
|
|
325
|
+
"const fs = require('node:fs');",
|
|
326
|
+
`process.on('SIGTERM', () => { fs.writeFileSync(${JSON.stringify(marker)}, 'signal'); setTimeout(() => { fs.writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0); }, 100); });`,
|
|
327
|
+
`fs.writeFileSync(${JSON.stringify(ready)}, 'ready');`,
|
|
328
|
+
"setTimeout(() => {}, 10000);",
|
|
329
|
+
].join(" ");
|
|
330
|
+
try {
|
|
331
|
+
await assert.rejects(() => runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, { secretStream: [entry] }), /Unable to write secret stream/);
|
|
332
|
+
let state;
|
|
333
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
334
|
+
try {
|
|
335
|
+
state = await readFile(marker, "utf8");
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
await delay(10);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
assert.equal(state, "closed");
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await delay(150);
|
|
346
|
+
await rm(directory, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
test("kills an unresponsive child after a fatal stream writer error", async () => {
|
|
350
|
+
if (process.platform === "win32")
|
|
351
|
+
return;
|
|
352
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "rhdh-e2e-exec-unresponsive-test-"));
|
|
353
|
+
const ready = path.join(directory, "ready");
|
|
354
|
+
let childReady = false;
|
|
355
|
+
let valueReads = 0;
|
|
356
|
+
const entry = {
|
|
357
|
+
name: "UNRESPONSIVE_STREAM_VALUE",
|
|
358
|
+
get value() {
|
|
359
|
+
valueReads++;
|
|
360
|
+
if (valueReads > 4) {
|
|
361
|
+
for (let attempt = 0; attempt < 100 && !existsSync(ready); attempt++) {
|
|
362
|
+
execFileSync("/bin/sh", ["-c", "sleep 0.01"], { stdio: "ignore" });
|
|
363
|
+
}
|
|
364
|
+
childReady = existsSync(ready);
|
|
365
|
+
throw new Error("synthetic writer failure");
|
|
366
|
+
}
|
|
367
|
+
return "synthetic-value";
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
const childScript = [
|
|
371
|
+
"const fs = require('node:fs');",
|
|
372
|
+
"process.on('SIGTERM', () => {});",
|
|
373
|
+
`fs.writeFileSync(${JSON.stringify(ready)}, 'ready');`,
|
|
374
|
+
"setTimeout(() => {}, 10000);",
|
|
375
|
+
].join(" ");
|
|
376
|
+
try {
|
|
377
|
+
const startedAt = Date.now();
|
|
378
|
+
await assert.rejects(() => runChild(process.execPath, ["-e", childScript], { ...process.env, RHDH_E2E_SECRET_FD: "3" }, { secretStream: [entry] }), /Unable to write secret stream/);
|
|
379
|
+
assert.equal(childReady, true);
|
|
380
|
+
assert.ok(Date.now() - startedAt < 2_000);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
await rm(directory, { recursive: true, force: true });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
test("does not write or leak values when the stream command cannot start", async () => {
|
|
387
|
+
await assert.rejects(() => runChild("command-that-does-not-exist", [], { ...process.env }, { secretStream: [{ name: "MISSING_COMMAND_VALUE", value: "secret" }] }), (error) => {
|
|
388
|
+
assert(error instanceof Error);
|
|
389
|
+
assert.equal(error.message, "Unable to start command: command-that-does-not-exist");
|
|
390
|
+
assert.equal(error.message.includes("secret"), false);
|
|
391
|
+
return true;
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
test("suggests stream mode when the child environment exceeds OS limits", async () => {
|
|
395
|
+
const oversizedEnvironmentSpawn = (() => {
|
|
396
|
+
throw Object.assign(new Error("spawn E2BIG"), { code: "E2BIG" });
|
|
397
|
+
});
|
|
398
|
+
await assert.rejects(() => createChildRunner(oversizedEnvironmentSpawn)(process.execPath, [], {
|
|
399
|
+
LARGE_VALUE: "synthetic-value",
|
|
400
|
+
}), (error) => {
|
|
401
|
+
assert(error instanceof Error);
|
|
402
|
+
assert.equal(error.message, `Unable to start command: ${process.execPath}; child environment exceeds OS limits; retry with --stream-secrets`);
|
|
403
|
+
return true;
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
test("preserves the child result when the secret pipe resets", async () => {
|
|
407
|
+
let child;
|
|
408
|
+
const resetPipe = new Writable({
|
|
409
|
+
write(_chunk, _encoding, callback) {
|
|
410
|
+
callback(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
|
|
411
|
+
setTimeout(() => child.emit("close", 17, null), 10);
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const resetPipeSpawn = (() => {
|
|
415
|
+
child = new EventEmitter();
|
|
416
|
+
Object.defineProperty(child, "stdio", {
|
|
417
|
+
value: [null, null, null, resetPipe],
|
|
418
|
+
});
|
|
419
|
+
child.kill = (() => true);
|
|
420
|
+
queueMicrotask(() => child.emit("spawn"));
|
|
421
|
+
return child;
|
|
422
|
+
});
|
|
423
|
+
const exitCode = await createChildRunner(resetPipeSpawn)(process.execPath, [], { RHDH_E2E_SECRET_FD: "3" }, {
|
|
424
|
+
secretStream: [{ name: "RESET_VALUE", value: "x".repeat(160 * 1024) }],
|
|
425
|
+
});
|
|
426
|
+
assert.equal(exitCode, 17);
|
|
427
|
+
});
|
|
428
|
+
test("rejects an unavailable secret stream pipe without environment fallback", async () => {
|
|
429
|
+
let spawnOptions;
|
|
430
|
+
const unavailablePipeSpawn = ((_command, _args, options) => {
|
|
431
|
+
spawnOptions = options;
|
|
432
|
+
const child = new EventEmitter();
|
|
433
|
+
Object.defineProperty(child, "stdio", {
|
|
434
|
+
value: [null, null, null, null],
|
|
435
|
+
});
|
|
436
|
+
child.kill = (() => {
|
|
437
|
+
queueMicrotask(() => child.emit("close", null, "SIGTERM"));
|
|
438
|
+
return true;
|
|
439
|
+
});
|
|
440
|
+
queueMicrotask(() => child.emit("spawn"));
|
|
441
|
+
return child;
|
|
442
|
+
});
|
|
443
|
+
await assert.rejects(() => executeCommand({
|
|
444
|
+
profile,
|
|
445
|
+
workspaces: [],
|
|
446
|
+
command: "unavailable-pipe-command",
|
|
447
|
+
args: [],
|
|
448
|
+
streamSecrets: true,
|
|
449
|
+
env: {
|
|
450
|
+
BW_SESSION: "synthetic-session",
|
|
451
|
+
VAULT_TOKEN: "stale-value",
|
|
452
|
+
},
|
|
453
|
+
client: { read: async () => [secret] },
|
|
454
|
+
childRunner: createChildRunner(unavailablePipeSpawn),
|
|
455
|
+
}), (error) => {
|
|
456
|
+
assert(error instanceof Error);
|
|
457
|
+
assert.equal(error.message, "Unable to write secret stream for command: unavailable-pipe-command");
|
|
458
|
+
assert.doesNotMatch(error.message, /synthetic|session|token/i);
|
|
459
|
+
return true;
|
|
460
|
+
});
|
|
461
|
+
assert.deepEqual(spawnOptions?.stdio, [
|
|
462
|
+
"inherit",
|
|
463
|
+
"inherit",
|
|
464
|
+
"inherit",
|
|
465
|
+
"pipe",
|
|
466
|
+
]);
|
|
467
|
+
assert.equal(spawnOptions?.env?.VAULT_TOKEN, undefined);
|
|
468
|
+
assert.equal(spawnOptions?.env?.BW_SESSION, undefined);
|
|
469
|
+
assert.equal(spawnOptions?.env?.RHDH_E2E_SECRET_FD, "3");
|
|
55
470
|
});
|
|
56
471
|
test("validates all selected mappings before starting the child", async () => {
|
|
57
472
|
let childStarted = false;
|
|
@@ -85,3 +500,55 @@ test("returns the shell-compatible status for a signal-terminated child", async
|
|
|
85
500
|
const exitCode = await runChild(process.execPath, ["-e", "process.kill(process.pid, 'SIGTERM')"], { ...process.env });
|
|
86
501
|
assert.equal(exitCode, 143);
|
|
87
502
|
});
|
|
503
|
+
test("forwards termination to descendants in the child process group", async () => {
|
|
504
|
+
if (process.platform === "win32")
|
|
505
|
+
return;
|
|
506
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "rhdh-e2e-exec-test-"));
|
|
507
|
+
const marker = path.join(directory, "terminated");
|
|
508
|
+
const ready = path.join(directory, "ready");
|
|
509
|
+
const pidFile = path.join(directory, "pid");
|
|
510
|
+
let descendantPid;
|
|
511
|
+
try {
|
|
512
|
+
const descendantScript = [
|
|
513
|
+
"const fs = require('node:fs');",
|
|
514
|
+
`process.on('SIGTERM', () => { fs.writeFileSync(${JSON.stringify(marker)}, 'terminated'); process.exit(0); });`,
|
|
515
|
+
`fs.writeFileSync(${JSON.stringify(ready)}, 'ready');`,
|
|
516
|
+
"setTimeout(() => {}, 10000);",
|
|
517
|
+
].join(" ");
|
|
518
|
+
const parentScript = [
|
|
519
|
+
"const fs = require('node:fs');",
|
|
520
|
+
"const { spawn } = require('node:child_process');",
|
|
521
|
+
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(descendantScript)}], { stdio: 'ignore' });`,
|
|
522
|
+
`fs.writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`,
|
|
523
|
+
"setTimeout(() => {}, 10000);",
|
|
524
|
+
].join(" ");
|
|
525
|
+
const result = runChild(process.execPath, ["-e", parentScript], {
|
|
526
|
+
...process.env,
|
|
527
|
+
});
|
|
528
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
529
|
+
try {
|
|
530
|
+
await readFile(ready, "utf8");
|
|
531
|
+
descendantPid = Number(await readFile(pidFile, "utf8"));
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
await delay(10);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
assert.equal(typeof descendantPid, "number");
|
|
539
|
+
process.kill(process.pid, "SIGTERM");
|
|
540
|
+
assert.equal(await result, 143);
|
|
541
|
+
assert.equal(await readFile(marker, "utf8"), "terminated");
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
if (descendantPid !== undefined) {
|
|
545
|
+
try {
|
|
546
|
+
process.kill(descendantPid, "SIGTERM");
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// The descendant may already have exited with the process group.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
await rm(directory, { recursive: true, force: true });
|
|
553
|
+
}
|
|
554
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gsm-wrapper.test.d.ts","sourceRoot":"","sources":["../../../src/secrets/tests/gsm-wrapper.test.ts"],"names":[],"mappings":""}
|