@red-hat-developer-hub/e2e-test-utils 2.1.11 → 2.1.13
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/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/package.json +6 -2
|
@@ -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 vault 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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@red-hat-developer-hub/e2e-test-utils",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.13",
|
|
4
4
|
"description": "Test utilities for RHDH E2E tests",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"./orchestrator": {
|
|
53
53
|
"types": "./dist/deployment/orchestrator/index.d.ts",
|
|
54
54
|
"default": "./dist/deployment/orchestrator/index.js"
|
|
55
|
+
},
|
|
56
|
+
"./openldap": {
|
|
57
|
+
"types": "./dist/deployment/openldap/index.d.ts",
|
|
58
|
+
"default": "./dist/deployment/openldap/index.js"
|
|
55
59
|
}
|
|
56
60
|
},
|
|
57
61
|
"publishConfig": {
|
|
@@ -62,7 +66,7 @@
|
|
|
62
66
|
"tsconfig.base.json"
|
|
63
67
|
],
|
|
64
68
|
"scripts": {
|
|
65
|
-
"build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/",
|
|
69
|
+
"build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/ && cp -r src/deployment/openldap/config dist/deployment/openldap/ && cp src/deployment/orchestrator/install-orchestrator.sh dist/deployment/orchestrator/",
|
|
66
70
|
"prepare": "husky",
|
|
67
71
|
"check": "yarn typecheck && yarn lint:check && yarn prettier:check",
|
|
68
72
|
"clean": "rm -rf dist",
|