@red-hat-developer-hub/e2e-test-utils 2.1.11 → 2.1.14
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/keycloak/deployment.d.ts +28 -1
- package/dist/deployment/keycloak/deployment.d.ts.map +1 -1
- package/dist/deployment/keycloak/deployment.js +210 -0
- package/dist/deployment/keycloak/index.d.ts +2 -1
- package/dist/deployment/keycloak/index.d.ts.map +1 -1
- package/dist/deployment/keycloak/index.js +1 -0
- package/dist/deployment/keycloak/types.d.ts +35 -0
- package/dist/deployment/keycloak/types.d.ts.map +1 -1
- package/dist/deployment/openldap/config/seed.ldif +102 -0
- package/dist/deployment/openldap/constants.d.ts +46 -0
- package/dist/deployment/openldap/constants.d.ts.map +1 -0
- package/dist/deployment/openldap/constants.js +65 -0
- package/dist/deployment/openldap/deployment.d.ts +31 -0
- package/dist/deployment/openldap/deployment.d.ts.map +1 -0
- package/dist/deployment/openldap/deployment.js +219 -0
- package/dist/deployment/openldap/index.d.ts +4 -0
- package/dist/deployment/openldap/index.d.ts.map +1 -0
- package/dist/deployment/openldap/index.js +2 -0
- package/dist/deployment/openldap/types.d.ts +34 -0
- package/dist/deployment/openldap/types.d.ts.map +1 -0
- package/dist/deployment/openldap/types.js +1 -0
- package/dist/playwright/global-setup.d.ts +5 -0
- package/dist/playwright/global-setup.d.ts.map +1 -1
- package/dist/playwright/global-setup.js +2 -4
- package/dist/playwright/helpers/common.d.ts +73 -2
- package/dist/playwright/helpers/common.d.ts.map +1 -1
- package/dist/playwright/helpers/common.js +159 -39
- package/dist/playwright/helpers/github-session.test.d.ts +2 -0
- package/dist/playwright/helpers/github-session.test.d.ts.map +1 -0
- package/dist/playwright/helpers/github-session.test.js +199 -0
- package/dist/playwright/tests/global-setup.test.d.ts +2 -0
- package/dist/playwright/tests/global-setup.test.d.ts.map +1 -0
- package/dist/playwright/tests/global-setup.test.js +68 -0
- package/dist/secrets/bitwarden.d.ts +34 -0
- package/dist/secrets/bitwarden.d.ts.map +1 -0
- package/dist/secrets/bitwarden.js +193 -0
- package/dist/secrets/cli.d.ts +11 -0
- package/dist/secrets/cli.d.ts.map +1 -0
- package/dist/secrets/cli.js +98 -0
- package/dist/secrets/command.d.ts +15 -0
- package/dist/secrets/command.d.ts.map +1 -0
- package/dist/secrets/command.js +30 -0
- package/dist/secrets/config.d.ts +33 -0
- package/dist/secrets/config.d.ts.map +1 -0
- package/dist/secrets/config.js +156 -0
- package/dist/secrets/environment.d.ts +10 -0
- package/dist/secrets/environment.d.ts.map +1 -0
- package/dist/secrets/environment.js +53 -0
- package/dist/secrets/exec.d.ts +18 -0
- package/dist/secrets/exec.d.ts.map +1 -0
- package/dist/secrets/exec.js +50 -0
- package/dist/secrets/index.d.ts +5 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +4 -0
- package/dist/secrets/tests/bitwarden.integration.test.d.ts +2 -0
- package/dist/secrets/tests/bitwarden.integration.test.d.ts.map +1 -0
- package/dist/secrets/tests/bitwarden.integration.test.js +50 -0
- package/dist/secrets/tests/bitwarden.test.d.ts +2 -0
- package/dist/secrets/tests/bitwarden.test.d.ts.map +1 -0
- package/dist/secrets/tests/bitwarden.test.js +418 -0
- package/dist/secrets/tests/cli.integration.test.d.ts +2 -0
- package/dist/secrets/tests/cli.integration.test.d.ts.map +1 -0
- package/dist/secrets/tests/cli.integration.test.js +70 -0
- package/dist/secrets/tests/cli.test.d.ts +2 -0
- package/dist/secrets/tests/cli.test.d.ts.map +1 -0
- package/dist/secrets/tests/cli.test.js +42 -0
- package/dist/secrets/tests/config.test.d.ts +2 -0
- package/dist/secrets/tests/config.test.d.ts.map +1 -0
- package/dist/secrets/tests/config.test.js +105 -0
- package/dist/secrets/tests/environment.test.d.ts +2 -0
- package/dist/secrets/tests/environment.test.d.ts.map +1 -0
- package/dist/secrets/tests/environment.test.js +119 -0
- package/dist/secrets/tests/exec.test.d.ts +2 -0
- package/dist/secrets/tests/exec.test.d.ts.map +1 -0
- package/dist/secrets/tests/exec.test.js +87 -0
- package/package.json +13 -2
- package/dist/utils/vault.d.ts +0 -16
- package/dist/utils/vault.d.ts.map +0 -1
- package/dist/utils/vault.js +0 -94
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { ensureGithubSession, githubSessionFile, readStoredCookies, withGithubSessionLock, writeStorageStateAtomically, } from "./common.js";
|
|
7
|
+
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "gh-session-test-"));
|
|
8
|
+
describe("github session file naming", () => {
|
|
9
|
+
it("gives two users different files, and is stable for one user", () => {
|
|
10
|
+
assert.notStrictEqual(githubSessionFile("user-a"), githubSessionFile("user-b"));
|
|
11
|
+
assert.strictEqual(githubSessionFile("rhdh-qe"), githubSessionFile("rhdh-qe"));
|
|
12
|
+
});
|
|
13
|
+
it("is deliberately not keyed by project", () => {
|
|
14
|
+
// Scoping per project was the obvious fix and is the wrong one: logintoGithub
|
|
15
|
+
// derives its 2FA code from one shared TOTP secret, so lanes logging in within the
|
|
16
|
+
// same 30-second window submit the identical code and GitHub rejects the second.
|
|
17
|
+
// Sharing the session is the point; withGithubSessionLock is what makes it safe.
|
|
18
|
+
const file = githubSessionFile("rhdh-qe");
|
|
19
|
+
assert.doesNotMatch(path.basename(file), /app-next|project/);
|
|
20
|
+
});
|
|
21
|
+
it("is absolute, so it does not follow a later chdir", () => {
|
|
22
|
+
assert.ok(path.isAbsolute(githubSessionFile("rhdh-qe")));
|
|
23
|
+
});
|
|
24
|
+
it("keeps a separator in the user id inside one file name", () => {
|
|
25
|
+
// Otherwise it becomes a directory that does not exist and the write fails.
|
|
26
|
+
assert.strictEqual(path.dirname(githubSessionFile("org/user")), process.cwd());
|
|
27
|
+
});
|
|
28
|
+
it("does not throw when the GitHub user id is unset", () => {
|
|
29
|
+
// loginAsGithubUser defaults to `process.env.VAULT_GH_USER_ID as string`, and the
|
|
30
|
+
// cast hides the undefined. Building the path must not be where that surfaces —
|
|
31
|
+
// a TypeError here points nowhere near the missing variable.
|
|
32
|
+
assert.doesNotThrow(() => githubSessionFile(undefined));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("the session lock", () => {
|
|
36
|
+
it("holds off a second caller until the first is done", async () => {
|
|
37
|
+
const dir = tmp();
|
|
38
|
+
const file = path.join(dir, "s.json");
|
|
39
|
+
const order = [];
|
|
40
|
+
let held = () => { };
|
|
41
|
+
const acquired = new Promise((resolve) => {
|
|
42
|
+
held = resolve;
|
|
43
|
+
});
|
|
44
|
+
// The second caller must not start until the first demonstrably holds the lock —
|
|
45
|
+
// racing them from the same tick would test the scheduler, not the lock.
|
|
46
|
+
const first = withGithubSessionLock(file, async () => {
|
|
47
|
+
order.push("first-in");
|
|
48
|
+
held();
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
50
|
+
order.push("first-out");
|
|
51
|
+
});
|
|
52
|
+
await acquired;
|
|
53
|
+
const second = withGithubSessionLock(file, async () => {
|
|
54
|
+
order.push("second-in");
|
|
55
|
+
});
|
|
56
|
+
await Promise.all([first, second]);
|
|
57
|
+
assert.deepStrictEqual(order, ["first-in", "first-out", "second-in"]);
|
|
58
|
+
});
|
|
59
|
+
it("releases the lock when the body throws", async () => {
|
|
60
|
+
const dir = tmp();
|
|
61
|
+
const file = path.join(dir, "s.json");
|
|
62
|
+
await assert.rejects(withGithubSessionLock(file, async () => {
|
|
63
|
+
throw new Error("boom");
|
|
64
|
+
}));
|
|
65
|
+
// A lock held after a failed login would hang every other lane for `stale`.
|
|
66
|
+
await withGithubSessionLock(file, async () => { });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("writing a stored session", () => {
|
|
70
|
+
/** Enough of a Page for the write path; a real one needs a browser. */
|
|
71
|
+
const fakePage = (write) => ({
|
|
72
|
+
context: () => ({
|
|
73
|
+
storageState: async ({ path: target }) => write(target),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
it("leaves only the final file behind", async () => {
|
|
77
|
+
const dir = tmp();
|
|
78
|
+
const file = path.join(dir, "s.json");
|
|
79
|
+
await writeStorageStateAtomically(fakePage((target) => fs.writeFileSync(target, '{"cookies":[]}')), file);
|
|
80
|
+
assert.deepStrictEqual(fs.readdirSync(dir), ["s.json"]);
|
|
81
|
+
});
|
|
82
|
+
it("removes the temp file when the write fails", async () => {
|
|
83
|
+
// Otherwise a failed run litters the workspace's e2e-tests directory, where
|
|
84
|
+
// nothing gitignores authState*.
|
|
85
|
+
const dir = tmp();
|
|
86
|
+
const file = path.join(dir, "s.json");
|
|
87
|
+
await assert.rejects(writeStorageStateAtomically(fakePage((target) => {
|
|
88
|
+
fs.writeFileSync(target, "partial");
|
|
89
|
+
throw new Error("browser went away");
|
|
90
|
+
}), file));
|
|
91
|
+
assert.deepStrictEqual(fs.readdirSync(dir), []);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("reading a stored session", () => {
|
|
95
|
+
it("returns the cookies of a well-formed file", () => {
|
|
96
|
+
const dir = tmp();
|
|
97
|
+
const file = path.join(dir, "s.json");
|
|
98
|
+
fs.writeFileSync(file, JSON.stringify({ cookies: [{ name: "a" }] }));
|
|
99
|
+
assert.deepStrictEqual(readStoredCookies(file), [{ name: "a" }]);
|
|
100
|
+
});
|
|
101
|
+
it("returns undefined rather than throwing on a truncated file", () => {
|
|
102
|
+
// What a concurrent reader saw while storageState() was mid-write. It used to
|
|
103
|
+
// come out of JSON.parse as a test failure that looked like a plugin bug.
|
|
104
|
+
const dir = tmp();
|
|
105
|
+
const file = path.join(dir, "s.json");
|
|
106
|
+
fs.writeFileSync(file, '{"cookies":[{"name"');
|
|
107
|
+
assert.strictEqual(readStoredCookies(file), undefined);
|
|
108
|
+
});
|
|
109
|
+
it("returns undefined for a file that does not exist", () => {
|
|
110
|
+
assert.strictEqual(readStoredCookies(path.join(tmp(), "absent.json")), undefined);
|
|
111
|
+
});
|
|
112
|
+
it("treats an empty cookie list as no session", () => {
|
|
113
|
+
// Reusing it would send the user through a login the caller thinks it skipped.
|
|
114
|
+
const dir = tmp();
|
|
115
|
+
const file = path.join(dir, "s.json");
|
|
116
|
+
fs.writeFileSync(file, JSON.stringify({ cookies: [] }));
|
|
117
|
+
assert.strictEqual(readStoredCookies(file), undefined);
|
|
118
|
+
});
|
|
119
|
+
it("treats a file with no cookies key as no session", () => {
|
|
120
|
+
const dir = tmp();
|
|
121
|
+
const file = path.join(dir, "s.json");
|
|
122
|
+
fs.writeFileSync(file, JSON.stringify({ origins: [] }));
|
|
123
|
+
assert.strictEqual(readStoredCookies(file), undefined);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("ensuring the shared session", () => {
|
|
127
|
+
const writeSession = (file) => fs.writeFileSync(file, JSON.stringify({ cookies: [{ name: "a" }] }));
|
|
128
|
+
it("reuses an existing session without taking the lock", async () => {
|
|
129
|
+
// The point of the split: reuse is cookies plus a Sign In against a different
|
|
130
|
+
// namespace host. Holding the lock across it made every lane queue behind one
|
|
131
|
+
// sign-in it did not need. Proven by holding the lock elsewhere — if reuse
|
|
132
|
+
// still waited on it, this would block until the holder released.
|
|
133
|
+
const dir = tmp();
|
|
134
|
+
const file = path.join(dir, "s.json");
|
|
135
|
+
writeSession(file);
|
|
136
|
+
let release = () => { };
|
|
137
|
+
const holding = new Promise((resolve) => {
|
|
138
|
+
release = resolve;
|
|
139
|
+
});
|
|
140
|
+
let held = () => { };
|
|
141
|
+
const acquired = new Promise((resolve) => {
|
|
142
|
+
held = resolve;
|
|
143
|
+
});
|
|
144
|
+
const holder = withGithubSessionLock(file, async () => {
|
|
145
|
+
held();
|
|
146
|
+
await holding;
|
|
147
|
+
});
|
|
148
|
+
// Wait until the lock is demonstrably held. Starting from the same tick races
|
|
149
|
+
// the scheduler instead of the lock, and the result then depends on how loaded
|
|
150
|
+
// the run is — it passed alone and passed under the full suite for different
|
|
151
|
+
// reasons, neither of them the one under test.
|
|
152
|
+
await acquired;
|
|
153
|
+
try {
|
|
154
|
+
// Bounded rather than a plain await: if reuse ever waits on the lock again
|
|
155
|
+
// this deadlocks, and a hung CI job is harder to read than a failed
|
|
156
|
+
// assertion. proper-lockfile retries for 60s, far past this deadline.
|
|
157
|
+
const outcome = await Promise.race([
|
|
158
|
+
ensureGithubSession(file, async () => {
|
|
159
|
+
throw new Error("must not create when a session already exists");
|
|
160
|
+
}),
|
|
161
|
+
new Promise((_, reject) => {
|
|
162
|
+
setTimeout(() => reject(new Error("reuse waited on the session lock")), 2_000).unref();
|
|
163
|
+
}),
|
|
164
|
+
]);
|
|
165
|
+
assert.strictEqual(outcome, "reused");
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
release();
|
|
169
|
+
await holder;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
it("creates once when two callers find no session at the same time", async () => {
|
|
173
|
+
// Both pass the outer check, so the re-read inside the lock is the only thing
|
|
174
|
+
// stopping the second from logging in again and submitting the same TOTP code.
|
|
175
|
+
const dir = tmp();
|
|
176
|
+
const file = path.join(dir, "s.json");
|
|
177
|
+
let creates = 0;
|
|
178
|
+
const create = async () => {
|
|
179
|
+
creates += 1;
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
181
|
+
writeSession(file);
|
|
182
|
+
};
|
|
183
|
+
const outcomes = await Promise.all([
|
|
184
|
+
ensureGithubSession(file, create),
|
|
185
|
+
ensureGithubSession(file, create),
|
|
186
|
+
]);
|
|
187
|
+
assert.strictEqual(creates, 1);
|
|
188
|
+
assert.deepStrictEqual(outcomes.filter((o) => o === "created").length, 1);
|
|
189
|
+
assert.deepStrictEqual(outcomes.filter((o) => o === "reused").length, 1);
|
|
190
|
+
});
|
|
191
|
+
it("reports creation so the caller does not replay the reuse path", async () => {
|
|
192
|
+
// Creating leaves the page signed in. A "created" that read as "reused" would
|
|
193
|
+
// click Sign In a second time against a live session.
|
|
194
|
+
const dir = tmp();
|
|
195
|
+
const file = path.join(dir, "s.json");
|
|
196
|
+
const outcome = await ensureGithubSession(file, async () => writeSession(file));
|
|
197
|
+
assert.strictEqual(outcome, "created");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"global-setup.test.d.ts","sourceRoot":"","sources":["../../../src/playwright/tests/global-setup.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* eslint-disable playwright/expect-expect, playwright/no-conditional-in-test -- this is a node:test regression suite */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { loadDotenvFromProjects } from "../global-setup.js";
|
|
8
|
+
test("workspace .env values override inherited values", async (context) => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "global-setup-test-"));
|
|
10
|
+
const e2eRoot = path.join(root, "e2e-tests");
|
|
11
|
+
const testDir = path.join(e2eRoot, "tests");
|
|
12
|
+
await mkdir(testDir, { recursive: true });
|
|
13
|
+
await writeFile(path.join(e2eRoot, ".env"), "VAULT_GITHUB_TOKEN=dotenv-value\nBW_SESSION=dotenv-session\nVAULT_TOKEN=dotenv-token\nLOCAL_ONLY=dotenv-only\n");
|
|
14
|
+
const previousToken = process.env.VAULT_GITHUB_TOKEN;
|
|
15
|
+
const previousSession = process.env.BW_SESSION;
|
|
16
|
+
const previousProviderToken = process.env.VAULT_TOKEN;
|
|
17
|
+
const previousLocal = process.env.LOCAL_ONLY;
|
|
18
|
+
const previousCi = process.env.CI;
|
|
19
|
+
try {
|
|
20
|
+
await context.test("local .env values override inherited values", () => {
|
|
21
|
+
delete process.env.CI;
|
|
22
|
+
process.env.VAULT_GITHUB_TOKEN = "bitwarden-value";
|
|
23
|
+
process.env.BW_SESSION = "parent-session";
|
|
24
|
+
process.env.VAULT_TOKEN = "parent-token";
|
|
25
|
+
delete process.env.LOCAL_ONLY;
|
|
26
|
+
loadDotenvFromProjects({ projects: [{ testDir }] });
|
|
27
|
+
assert.equal(process.env.VAULT_GITHUB_TOKEN, "dotenv-value");
|
|
28
|
+
assert.equal(process.env.BW_SESSION, "dotenv-session");
|
|
29
|
+
assert.equal(process.env.VAULT_TOKEN, "dotenv-token");
|
|
30
|
+
assert.equal(process.env.LOCAL_ONLY, "dotenv-only");
|
|
31
|
+
});
|
|
32
|
+
await context.test("workspace .env values override CI values", () => {
|
|
33
|
+
process.env.CI = "true";
|
|
34
|
+
process.env.VAULT_GITHUB_TOKEN = "ci-value";
|
|
35
|
+
process.env.BW_SESSION = "ci-session";
|
|
36
|
+
process.env.VAULT_TOKEN = "ci-token";
|
|
37
|
+
delete process.env.LOCAL_ONLY;
|
|
38
|
+
loadDotenvFromProjects({ projects: [{ testDir }] });
|
|
39
|
+
assert.equal(process.env.VAULT_GITHUB_TOKEN, "dotenv-value");
|
|
40
|
+
assert.equal(process.env.BW_SESSION, "dotenv-session");
|
|
41
|
+
assert.equal(process.env.VAULT_TOKEN, "dotenv-token");
|
|
42
|
+
assert.equal(process.env.LOCAL_ONLY, "dotenv-only");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (previousToken === undefined)
|
|
47
|
+
delete process.env.VAULT_GITHUB_TOKEN;
|
|
48
|
+
else
|
|
49
|
+
process.env.VAULT_GITHUB_TOKEN = previousToken;
|
|
50
|
+
if (previousSession === undefined)
|
|
51
|
+
delete process.env.BW_SESSION;
|
|
52
|
+
else
|
|
53
|
+
process.env.BW_SESSION = previousSession;
|
|
54
|
+
if (previousProviderToken === undefined)
|
|
55
|
+
delete process.env.VAULT_TOKEN;
|
|
56
|
+
else
|
|
57
|
+
process.env.VAULT_TOKEN = previousProviderToken;
|
|
58
|
+
if (previousLocal === undefined)
|
|
59
|
+
delete process.env.LOCAL_ONLY;
|
|
60
|
+
else
|
|
61
|
+
process.env.LOCAL_ONLY = previousLocal;
|
|
62
|
+
if (previousCi === undefined)
|
|
63
|
+
delete process.env.CI;
|
|
64
|
+
else
|
|
65
|
+
process.env.CI = previousCi;
|
|
66
|
+
await rm(root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type CommandResult } from "./command.js";
|
|
2
|
+
import { type ExpandedSecretSelector, type ReadableCollectionId } from "./config.js";
|
|
3
|
+
export interface BitwardenCommandRunner {
|
|
4
|
+
(command: string, args: readonly string[], options?: {
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
input?: string;
|
|
7
|
+
}): Promise<CommandResult>;
|
|
8
|
+
}
|
|
9
|
+
export interface BitwardenSecret {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
value: string;
|
|
13
|
+
selector: ExpandedSecretSelector;
|
|
14
|
+
}
|
|
15
|
+
export interface BitwardenAttachment {
|
|
16
|
+
id: string;
|
|
17
|
+
fileName: string;
|
|
18
|
+
}
|
|
19
|
+
export interface BitwardenClientOptions {
|
|
20
|
+
command?: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
runner?: BitwardenCommandRunner;
|
|
23
|
+
}
|
|
24
|
+
export declare class BitwardenClient {
|
|
25
|
+
private readonly command;
|
|
26
|
+
private readonly env;
|
|
27
|
+
private readonly runner;
|
|
28
|
+
constructor(options?: BitwardenClientOptions);
|
|
29
|
+
read(collectionId: ReadableCollectionId, selectors: readonly ExpandedSecretSelector[]): Promise<BitwardenSecret[]>;
|
|
30
|
+
private resolveCollection;
|
|
31
|
+
private readSelector;
|
|
32
|
+
private runOrThrow;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=bitwarden.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bitwarden.d.ts","sourceRoot":"","sources":["../../src/secrets/bitwarden.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAEL,KAAK,sBAAsB,EAC3B,KAAK,oBAAoB,EAC1B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,sBAAsB;IACrC,CACE,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE;QACR,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;QACxB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,GACA,OAAO,CAAC,aAAa,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,sBAAsB,CAAC;CAClC;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;CAClB;AAQD,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,MAAM,CAAC,EAAE,sBAAsB,CAAC;CACjC;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAoB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyB;gBAEpC,OAAO,GAAE,sBAA2B;IAM1C,IAAI,CACR,YAAY,EAAE,oBAAoB,EAClC,SAAS,EAAE,SAAS,sBAAsB,EAAE,GAC3C,OAAO,CAAC,eAAe,EAAE,CAAC;YA4Cf,iBAAiB;YA0CjB,YAAY;YA0HZ,UAAU;CAazB"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { runCommand } from "./command.js";
|
|
2
|
+
import { getCollectionMapping, } from "./config.js";
|
|
3
|
+
export class BitwardenClient {
|
|
4
|
+
command;
|
|
5
|
+
env;
|
|
6
|
+
runner;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.command = options.command ?? "bw";
|
|
9
|
+
this.env = { ...process.env, ...options.env };
|
|
10
|
+
this.runner = options.runner ?? runCommand;
|
|
11
|
+
}
|
|
12
|
+
async read(collectionId, selectors) {
|
|
13
|
+
const mapping = getCollectionMapping(collectionId);
|
|
14
|
+
if (!this.env.BW_SESSION?.trim()) {
|
|
15
|
+
throw new Error("BW_SESSION is required and must contain an unlocked Bitwarden session");
|
|
16
|
+
}
|
|
17
|
+
await this.runOrThrow(["--version"], "Bitwarden CLI is unavailable");
|
|
18
|
+
const status = await this.runOrThrow(["status"], "Bitwarden session status could not be checked");
|
|
19
|
+
const statusJson = parseJson(status, "Bitwarden status");
|
|
20
|
+
if (!isRecord(statusJson) || statusJson.status !== "unlocked") {
|
|
21
|
+
throw new Error("BW_SESSION is missing or Bitwarden is not unlocked");
|
|
22
|
+
}
|
|
23
|
+
await this.runOrThrow(["sync"], "Bitwarden sync failed");
|
|
24
|
+
const collection = await this.resolveCollection(mapping.bitwardenCollection);
|
|
25
|
+
const result = [];
|
|
26
|
+
const names = new Set();
|
|
27
|
+
for (const selector of selectors) {
|
|
28
|
+
const items = await this.readSelector(collection, selector);
|
|
29
|
+
if (items.length === 0 && !selector.optional) {
|
|
30
|
+
throw new Error(`Required prefix has no matching item: ${selector.prefix}`);
|
|
31
|
+
}
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
if (names.has(item.name)) {
|
|
34
|
+
throw new Error(`Duplicate Bitwarden item name: ${item.name}`);
|
|
35
|
+
}
|
|
36
|
+
names.add(item.name);
|
|
37
|
+
result.push(item);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
async resolveCollection(collectionName) {
|
|
43
|
+
const result = await this.runOrThrow(["list", "collections"], "Bitwarden collection listing failed");
|
|
44
|
+
const values = parseJson(result, "Bitwarden collection list");
|
|
45
|
+
if (!Array.isArray(values)) {
|
|
46
|
+
throw new Error("Bitwarden returned an invalid collection list");
|
|
47
|
+
}
|
|
48
|
+
const matches = values.filter((value) => isRecord(value) && value.name === collectionName);
|
|
49
|
+
if (matches.length === 0) {
|
|
50
|
+
throw new Error(`Bitwarden collection not found: ${collectionName}`);
|
|
51
|
+
}
|
|
52
|
+
if (matches.length > 1) {
|
|
53
|
+
throw new Error(`Bitwarden collection name is ambiguous: ${collectionName}`);
|
|
54
|
+
}
|
|
55
|
+
const match = matches[0];
|
|
56
|
+
if (typeof match.id !== "string" ||
|
|
57
|
+
typeof match.name !== "string" ||
|
|
58
|
+
typeof match.organizationId !== "string") {
|
|
59
|
+
throw new Error(`Bitwarden collection has invalid metadata: ${collectionName}`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
id: match.id,
|
|
63
|
+
name: match.name,
|
|
64
|
+
organizationId: match.organizationId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async readSelector(collection, selector) {
|
|
68
|
+
const result = await this.runOrThrow([
|
|
69
|
+
"list",
|
|
70
|
+
"items",
|
|
71
|
+
"--collectionid",
|
|
72
|
+
collection.id,
|
|
73
|
+
"--search",
|
|
74
|
+
selector.prefix,
|
|
75
|
+
], `Bitwarden item listing failed for ${selector.prefix}`);
|
|
76
|
+
const values = parseJson(result, `Bitwarden item list for ${selector.prefix}`);
|
|
77
|
+
if (!Array.isArray(values)) {
|
|
78
|
+
throw new Error(`Bitwarden returned an invalid item list for ${selector.prefix}`);
|
|
79
|
+
}
|
|
80
|
+
const ids = values.flatMap((value) => {
|
|
81
|
+
if (!isRecord(value) ||
|
|
82
|
+
typeof value.id !== "string" ||
|
|
83
|
+
value.id.length === 0) {
|
|
84
|
+
throw new Error(`Bitwarden returned an item without an id for ${selector.prefix}`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof value.name === "string" &&
|
|
87
|
+
!value.name.startsWith(selector.prefix)) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return [value.id];
|
|
91
|
+
});
|
|
92
|
+
if (new Set(ids).size !== ids.length) {
|
|
93
|
+
throw new Error(`Duplicate Bitwarden item id for ${selector.prefix}`);
|
|
94
|
+
}
|
|
95
|
+
const items = [];
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const itemResult = await this.runOrThrow(["get", "item", id], `Bitwarden item read failed for ${selector.prefix}`);
|
|
98
|
+
const value = parseJson(itemResult, `Bitwarden item ${id}`);
|
|
99
|
+
if (!isRecord(value)) {
|
|
100
|
+
throw new Error(`Bitwarden returned an invalid item for ${selector.prefix}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof value.name !== "string" ||
|
|
103
|
+
!value.name.startsWith(selector.prefix)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (value.id !== id ||
|
|
107
|
+
value.type !== 2 ||
|
|
108
|
+
!Array.isArray(value.collectionIds) ||
|
|
109
|
+
value.collectionIds.length !== 1 ||
|
|
110
|
+
value.collectionIds[0] !== collection.id ||
|
|
111
|
+
value.organizationId !== collection.organizationId) {
|
|
112
|
+
throw new Error(`Bitwarden item ${typeof value.name === "string" ? value.name : id} is not a secure note in the selected collection`);
|
|
113
|
+
}
|
|
114
|
+
const attachment = parseAttachment(value.attachments, selector.prefix);
|
|
115
|
+
if (attachment === undefined) {
|
|
116
|
+
if (typeof value.notes !== "string") {
|
|
117
|
+
throw new Error(`Bitwarden item ${value.name} is not a secure note in the selected collection`);
|
|
118
|
+
}
|
|
119
|
+
items.push({
|
|
120
|
+
id,
|
|
121
|
+
name: value.name,
|
|
122
|
+
value: value.notes,
|
|
123
|
+
selector,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (value.notes !== null &&
|
|
128
|
+
(typeof value.notes !== "string" || value.notes.length > 0)) {
|
|
129
|
+
throw new Error(`Bitwarden item notes must be null or empty when an attachment is present for ${selector.prefix}`);
|
|
130
|
+
}
|
|
131
|
+
if (attachment.fileName !== attachmentFileName(value.name)) {
|
|
132
|
+
throw new Error(`Bitwarden attachment filename does not match the item basename for ${selector.prefix}`);
|
|
133
|
+
}
|
|
134
|
+
const attachmentResult = await this.runOrThrow(["get", "attachment", attachment.fileName, "--itemid", id, "--raw"], `Bitwarden attachment read failed for ${selector.prefix}`);
|
|
135
|
+
items.push({
|
|
136
|
+
id,
|
|
137
|
+
name: value.name,
|
|
138
|
+
value: attachmentResult.stdout,
|
|
139
|
+
selector,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return items;
|
|
143
|
+
}
|
|
144
|
+
async runOrThrow(args, message) {
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = await this.runner(this.command, args, { env: this.env });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error(message);
|
|
151
|
+
}
|
|
152
|
+
if (result.status !== 0)
|
|
153
|
+
throw new Error(message);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function parseJson(result, label) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(result.stdout);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new Error(`${label} returned invalid JSON`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function isRecord(value) {
|
|
166
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
|
+
}
|
|
168
|
+
function parseAttachment(value, prefix) {
|
|
169
|
+
if (value === undefined || value === null)
|
|
170
|
+
return undefined;
|
|
171
|
+
if (!Array.isArray(value)) {
|
|
172
|
+
throw new Error(`Bitwarden item has invalid attachment metadata for ${prefix}`);
|
|
173
|
+
}
|
|
174
|
+
if (value.length === 0)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (value.length !== 1) {
|
|
177
|
+
throw new Error(`Bitwarden item must have exactly one attachment for ${prefix}`);
|
|
178
|
+
}
|
|
179
|
+
const attachment = value[0];
|
|
180
|
+
if (!isRecord(attachment) ||
|
|
181
|
+
typeof attachment.id !== "string" ||
|
|
182
|
+
attachment.id.length === 0 ||
|
|
183
|
+
typeof attachment.fileName !== "string" ||
|
|
184
|
+
attachment.fileName.length === 0) {
|
|
185
|
+
throw new Error(`Bitwarden item has invalid attachment metadata for ${prefix}`);
|
|
186
|
+
}
|
|
187
|
+
return { id: attachment.id, fileName: attachment.fileName };
|
|
188
|
+
}
|
|
189
|
+
function attachmentFileName(name) {
|
|
190
|
+
const basename = name.split("/").at(-1) ?? name;
|
|
191
|
+
const sanitized = basename.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
192
|
+
return sanitized || "secret";
|
|
193
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface ExecCliArguments {
|
|
3
|
+
command: "exec";
|
|
4
|
+
profilePath: string;
|
|
5
|
+
workspaces: string[];
|
|
6
|
+
executable: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function parseCliArguments(argv: readonly string[]): ExecCliArguments;
|
|
10
|
+
export declare function main(argv?: readonly string[]): Promise<number>;
|
|
11
|
+
//# sourceMappingURL=cli.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/secrets/cli.ts"],"names":[],"mappings":";AASA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAUD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAkD3E;AAED,wBAAsB,IAAI,CACxB,IAAI,GAAE,SAAS,MAAM,EAA0B,GAC9C,OAAO,CAAC,MAAM,CAAC,CAwBjB"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { executeCommand } from "./exec.js";
|
|
7
|
+
import { parseProfile } from "./config.js";
|
|
8
|
+
const HELP = `Usage:
|
|
9
|
+
rhdh-e2e-secrets exec --profile <profile.json> [--workspace <name> ...] -- <command> [args...]
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
BW_SESSION must contain an already unlocked Bitwarden CLI session.
|
|
13
|
+
The bw CLI must be installed and available on PATH.
|
|
14
|
+
`;
|
|
15
|
+
export function parseCliArguments(argv) {
|
|
16
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
17
|
+
throw new Error(HELP);
|
|
18
|
+
}
|
|
19
|
+
if (argv[0] !== "exec") {
|
|
20
|
+
throw new Error(`Unsupported command: ${argv[0]}`);
|
|
21
|
+
}
|
|
22
|
+
const delimiter = argv.indexOf("--");
|
|
23
|
+
if (delimiter === -1) {
|
|
24
|
+
throw new Error("A command after -- is required");
|
|
25
|
+
}
|
|
26
|
+
const options = argv.slice(1, delimiter);
|
|
27
|
+
const command = argv[delimiter + 1];
|
|
28
|
+
const args = argv.slice(delimiter + 2);
|
|
29
|
+
let profilePath;
|
|
30
|
+
const workspaces = [];
|
|
31
|
+
for (let index = 0; index < options.length; index++) {
|
|
32
|
+
const option = options[index];
|
|
33
|
+
if (option === "--profile" || option === "--workspace") {
|
|
34
|
+
const value = options[++index];
|
|
35
|
+
if (!value)
|
|
36
|
+
throw new Error(`${option} requires a value`);
|
|
37
|
+
if (option === "--profile")
|
|
38
|
+
profilePath = value;
|
|
39
|
+
else
|
|
40
|
+
workspaces.push(value);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (option.startsWith("--profile=")) {
|
|
44
|
+
profilePath = option.slice("--profile=".length);
|
|
45
|
+
if (!profilePath)
|
|
46
|
+
throw new Error("--profile requires a value");
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (option.startsWith("--workspace=")) {
|
|
50
|
+
const workspace = option.slice("--workspace=".length);
|
|
51
|
+
if (!workspace)
|
|
52
|
+
throw new Error("--workspace requires a value");
|
|
53
|
+
workspaces.push(workspace);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Unknown option: ${option}`);
|
|
57
|
+
}
|
|
58
|
+
if (!profilePath)
|
|
59
|
+
throw new Error("--profile is required");
|
|
60
|
+
if (!command)
|
|
61
|
+
throw new Error("A non-empty command after -- is required");
|
|
62
|
+
return {
|
|
63
|
+
command: "exec",
|
|
64
|
+
profilePath,
|
|
65
|
+
workspaces,
|
|
66
|
+
executable: command,
|
|
67
|
+
args,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
71
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
72
|
+
console.log(HELP);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = parseCliArguments(argv);
|
|
77
|
+
const profileValue = JSON.parse(await readFile(resolve(parsed.profilePath), "utf8"));
|
|
78
|
+
const profile = parseProfile(profileValue);
|
|
79
|
+
return await executeCommand({
|
|
80
|
+
profile,
|
|
81
|
+
workspaces: parsed.workspaces,
|
|
82
|
+
command: parsed.executable,
|
|
83
|
+
args: parsed.args,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.error(error instanceof Error ? error.message : "Secret execution failed");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (process.argv[1] &&
|
|
92
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
93
|
+
void main().then((exitCode) => {
|
|
94
|
+
process.exitCode = exitCode;
|
|
95
|
+
}, () => {
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface CommandResult {
|
|
2
|
+
status: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
error?: Error;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandOptions {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
input?: string;
|
|
11
|
+
stdio?: "pipe" | "inherit";
|
|
12
|
+
}
|
|
13
|
+
export type CommandRunner = (command: string, args: readonly string[], options?: CommandOptions) => Promise<CommandResult>;
|
|
14
|
+
export declare const runCommand: CommandRunner;
|
|
15
|
+
//# sourceMappingURL=command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/secrets/command.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED,MAAM,MAAM,aAAa,GAAG,CAC1B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE,cAAc,KACrB,OAAO,CAAC,aAAa,CAAC,CAAC;AAE5B,eAAO,MAAM,UAAU,EAAE,aA6BrB,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export const runCommand = (command, args, options = {}) => new Promise((resolve) => {
|
|
3
|
+
const child = spawn(command, args, {
|
|
4
|
+
cwd: options.cwd,
|
|
5
|
+
env: options.env,
|
|
6
|
+
stdio: options.stdio === "inherit" ? "inherit" : "pipe",
|
|
7
|
+
});
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
if (options.stdio !== "inherit") {
|
|
11
|
+
child.stdout?.setEncoding("utf8");
|
|
12
|
+
child.stderr?.setEncoding("utf8");
|
|
13
|
+
child.stdout?.on("data", (chunk) => {
|
|
14
|
+
stdout += chunk;
|
|
15
|
+
});
|
|
16
|
+
child.stderr?.on("data", (chunk) => {
|
|
17
|
+
stderr += chunk;
|
|
18
|
+
});
|
|
19
|
+
if (options.input !== undefined)
|
|
20
|
+
child.stdin?.end(options.input);
|
|
21
|
+
else
|
|
22
|
+
child.stdin?.end();
|
|
23
|
+
}
|
|
24
|
+
child.once("error", (error) => {
|
|
25
|
+
resolve({ status: null, stdout, stderr, error });
|
|
26
|
+
});
|
|
27
|
+
child.once("close", (status) => {
|
|
28
|
+
resolve({ status, stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
});
|