@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,219 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { KubernetesClientHelper } from "../../utils/kubernetes-client.js";
|
|
3
|
+
import { $, runQuietUnlessFailure } from "../../utils/bash.js";
|
|
4
|
+
import { DEFAULT_OPENLDAP_CONFIG, DEFAULT_CONFIG_PATHS, buildBindDn, buildUsersDn, buildGroupsDn, } from "./constants.js";
|
|
5
|
+
/**
|
|
6
|
+
* Orchestrator-style OpenLDAP helper (Bitnami legacy image).
|
|
7
|
+
* Call from test.runOnce — not globalSetup. Deploys into the Playwright project namespace.
|
|
8
|
+
*/
|
|
9
|
+
export class OpenLDAPHelper {
|
|
10
|
+
k8sClient = new KubernetesClientHelper();
|
|
11
|
+
deploymentConfig;
|
|
12
|
+
ldapUrl = "";
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.deploymentConfig = this._buildDeploymentConfig(options);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Deploy OpenLDAP into the given namespace (creates namespace if needed).
|
|
18
|
+
*/
|
|
19
|
+
async deploy(namespace) {
|
|
20
|
+
this.deploymentConfig.namespace = namespace;
|
|
21
|
+
this._log(`Starting OpenLDAP deployment in ${namespace}...`);
|
|
22
|
+
await this.k8sClient.createNamespaceIfNotExists(namespace);
|
|
23
|
+
await this._applyManifests();
|
|
24
|
+
await this.waitUntilReady();
|
|
25
|
+
this.ldapUrl = this.getServiceUrl();
|
|
26
|
+
this._log(`OpenLDAP ready at ${this.ldapUrl}`);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* True if the OpenLDAP service already exists in the configured namespace.
|
|
30
|
+
*/
|
|
31
|
+
async isRunning() {
|
|
32
|
+
const { namespace, releaseName } = this.deploymentConfig;
|
|
33
|
+
if (!namespace) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const result = await $ `kubectl get svc ${releaseName} -n ${namespace} -o name`.nothrow();
|
|
38
|
+
return result.exitCode === 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Cluster-internal LDAP URL (Keycloak federation / RHDH ldapOrg). */
|
|
45
|
+
getServiceUrl() {
|
|
46
|
+
const { releaseName, namespace, port } = this.deploymentConfig;
|
|
47
|
+
if (!namespace) {
|
|
48
|
+
throw new Error("OpenLDAP namespace is not set — call deploy(namespace) first");
|
|
49
|
+
}
|
|
50
|
+
return `ldap://${releaseName}.${namespace}.svc.cluster.local:${port}`;
|
|
51
|
+
}
|
|
52
|
+
getBindConfig() {
|
|
53
|
+
const { adminPassword, baseDn, adminUser, usersOu, groupsOu } = this.deploymentConfig;
|
|
54
|
+
return {
|
|
55
|
+
bindDn: buildBindDn({ adminUser, baseDn }),
|
|
56
|
+
bindSecret: adminPassword,
|
|
57
|
+
usersDn: buildUsersDn({ usersOu, baseDn }),
|
|
58
|
+
groupsDn: buildGroupsDn({ groupsOu, baseDn }),
|
|
59
|
+
baseDn,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Export LDAP_* env vars for RHDH secrets / app-config substitution. */
|
|
63
|
+
exportEnv() {
|
|
64
|
+
const bind = this.getBindConfig();
|
|
65
|
+
process.env.LDAP_TARGET_URL = this.getServiceUrl();
|
|
66
|
+
process.env.LDAP_BIND_DN = bind.bindDn;
|
|
67
|
+
process.env.LDAP_BIND_SECRET = bind.bindSecret;
|
|
68
|
+
process.env.LDAP_USERS_DN = bind.usersDn;
|
|
69
|
+
process.env.LDAP_GROUPS_DN = bind.groupsDn;
|
|
70
|
+
}
|
|
71
|
+
async waitUntilReady(timeout = 300) {
|
|
72
|
+
const { namespace, releaseName } = this.deploymentConfig;
|
|
73
|
+
this._log("Waiting for OpenLDAP pods...");
|
|
74
|
+
const labelSelector = `app.kubernetes.io/name=openldap,app.kubernetes.io/instance=${releaseName}`;
|
|
75
|
+
await this.k8sClient.waitForPodsWithFailureDetection(namespace, labelSelector, timeout);
|
|
76
|
+
}
|
|
77
|
+
async teardown() {
|
|
78
|
+
const { namespace, releaseName } = this.deploymentConfig;
|
|
79
|
+
this._log(`Tearing down OpenLDAP ${releaseName} in ${namespace}...`);
|
|
80
|
+
await $ `kubectl delete deployment,svc,configmap -l app.kubernetes.io/instance=${releaseName} -n ${namespace} --ignore-not-found=true`.nothrow();
|
|
81
|
+
}
|
|
82
|
+
_buildDeploymentConfig(options) {
|
|
83
|
+
return {
|
|
84
|
+
namespace: "",
|
|
85
|
+
releaseName: options.releaseName ?? DEFAULT_OPENLDAP_CONFIG.releaseName,
|
|
86
|
+
adminUser: options.adminUser ?? DEFAULT_OPENLDAP_CONFIG.adminUser,
|
|
87
|
+
adminPassword: options.adminPassword ?? DEFAULT_OPENLDAP_CONFIG.adminPassword,
|
|
88
|
+
baseDn: options.baseDn ?? DEFAULT_OPENLDAP_CONFIG.baseDn,
|
|
89
|
+
usersOu: options.usersOu ?? DEFAULT_OPENLDAP_CONFIG.usersOu,
|
|
90
|
+
groupsOu: options.groupsOu ?? DEFAULT_OPENLDAP_CONFIG.groupsOu,
|
|
91
|
+
port: options.port ?? DEFAULT_OPENLDAP_CONFIG.port,
|
|
92
|
+
imageRepository: options.imageRepository ?? DEFAULT_OPENLDAP_CONFIG.imageRepository,
|
|
93
|
+
imageTag: options.imageTag ?? DEFAULT_OPENLDAP_CONFIG.imageTag,
|
|
94
|
+
seedLdifFile: options.seedLdifFile ?? DEFAULT_CONFIG_PATHS.seedLdifFile,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async _applyManifests() {
|
|
98
|
+
const cfg = this.deploymentConfig;
|
|
99
|
+
if (!fs.existsSync(cfg.seedLdifFile)) {
|
|
100
|
+
throw new Error(`OpenLDAP seed LDIF not found: ${cfg.seedLdifFile}`);
|
|
101
|
+
}
|
|
102
|
+
const seedContent = fs.readFileSync(cfg.seedLdifFile, "utf-8");
|
|
103
|
+
const manifest = `
|
|
104
|
+
apiVersion: v1
|
|
105
|
+
kind: ConfigMap
|
|
106
|
+
metadata:
|
|
107
|
+
name: ${cfg.releaseName}-seed
|
|
108
|
+
namespace: ${cfg.namespace}
|
|
109
|
+
labels:
|
|
110
|
+
app.kubernetes.io/name: openldap
|
|
111
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
112
|
+
data:
|
|
113
|
+
seed.ldif: |
|
|
114
|
+
${seedContent
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => ` ${line}`)
|
|
117
|
+
.join("\n")}
|
|
118
|
+
---
|
|
119
|
+
apiVersion: v1
|
|
120
|
+
kind: Service
|
|
121
|
+
metadata:
|
|
122
|
+
name: ${cfg.releaseName}
|
|
123
|
+
namespace: ${cfg.namespace}
|
|
124
|
+
labels:
|
|
125
|
+
app.kubernetes.io/name: openldap
|
|
126
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
127
|
+
spec:
|
|
128
|
+
type: ClusterIP
|
|
129
|
+
selector:
|
|
130
|
+
app.kubernetes.io/name: openldap
|
|
131
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
132
|
+
ports:
|
|
133
|
+
- name: ldap
|
|
134
|
+
port: ${cfg.port}
|
|
135
|
+
targetPort: ldap
|
|
136
|
+
---
|
|
137
|
+
apiVersion: apps/v1
|
|
138
|
+
kind: Deployment
|
|
139
|
+
metadata:
|
|
140
|
+
name: ${cfg.releaseName}
|
|
141
|
+
namespace: ${cfg.namespace}
|
|
142
|
+
labels:
|
|
143
|
+
app.kubernetes.io/name: openldap
|
|
144
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
145
|
+
spec:
|
|
146
|
+
replicas: 1
|
|
147
|
+
selector:
|
|
148
|
+
matchLabels:
|
|
149
|
+
app.kubernetes.io/name: openldap
|
|
150
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
151
|
+
template:
|
|
152
|
+
metadata:
|
|
153
|
+
labels:
|
|
154
|
+
app.kubernetes.io/name: openldap
|
|
155
|
+
app.kubernetes.io/instance: ${cfg.releaseName}
|
|
156
|
+
spec:
|
|
157
|
+
containers:
|
|
158
|
+
- name: openldap
|
|
159
|
+
image: ${cfg.imageRepository}:${cfg.imageTag}
|
|
160
|
+
imagePullPolicy: IfNotPresent
|
|
161
|
+
ports:
|
|
162
|
+
- name: ldap
|
|
163
|
+
containerPort: ${cfg.port}
|
|
164
|
+
# Bitnami slapd/slappasswd carry setcap CAP_NET_BIND_SERVICE; OpenShift
|
|
165
|
+
# restricted-v2 drops ALL capabilities unless NET_BIND_SERVICE is added
|
|
166
|
+
# explicitly (otherwise: "slappasswd: Operation not permitted").
|
|
167
|
+
securityContext:
|
|
168
|
+
allowPrivilegeEscalation: false
|
|
169
|
+
capabilities:
|
|
170
|
+
drop: ["ALL"]
|
|
171
|
+
add: ["NET_BIND_SERVICE"]
|
|
172
|
+
runAsNonRoot: true
|
|
173
|
+
env:
|
|
174
|
+
- name: LDAP_ROOT
|
|
175
|
+
value: "${cfg.baseDn}"
|
|
176
|
+
- name: LDAP_ADMIN_USERNAME
|
|
177
|
+
value: "${cfg.adminUser}"
|
|
178
|
+
- name: LDAP_ADMIN_PASSWORD
|
|
179
|
+
value: "${cfg.adminPassword}"
|
|
180
|
+
- name: LDAP_PORT_NUMBER
|
|
181
|
+
value: "${cfg.port}"
|
|
182
|
+
- name: LDAP_CUSTOM_LDIF_DIR
|
|
183
|
+
value: /ldifs
|
|
184
|
+
- name: LDAP_ALLOW_ANON_BINDING
|
|
185
|
+
value: "no"
|
|
186
|
+
volumeMounts:
|
|
187
|
+
- name: seed
|
|
188
|
+
mountPath: /ldifs
|
|
189
|
+
readOnly: true
|
|
190
|
+
readinessProbe:
|
|
191
|
+
tcpSocket:
|
|
192
|
+
port: ldap
|
|
193
|
+
initialDelaySeconds: 10
|
|
194
|
+
periodSeconds: 5
|
|
195
|
+
failureThreshold: 12
|
|
196
|
+
livenessProbe:
|
|
197
|
+
tcpSocket:
|
|
198
|
+
port: ldap
|
|
199
|
+
initialDelaySeconds: 30
|
|
200
|
+
periodSeconds: 10
|
|
201
|
+
failureThreshold: 6
|
|
202
|
+
resources:
|
|
203
|
+
requests:
|
|
204
|
+
cpu: 50m
|
|
205
|
+
memory: 128Mi
|
|
206
|
+
limits:
|
|
207
|
+
cpu: 500m
|
|
208
|
+
memory: 512Mi
|
|
209
|
+
volumes:
|
|
210
|
+
- name: seed
|
|
211
|
+
configMap:
|
|
212
|
+
name: ${cfg.releaseName}-seed
|
|
213
|
+
`;
|
|
214
|
+
await runQuietUnlessFailure `echo ${manifest} | kubectl apply -f -`;
|
|
215
|
+
}
|
|
216
|
+
_log(message) {
|
|
217
|
+
console.log(`[OpenLDAP] ${message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { OpenLDAPHelper } from "./deployment.js";
|
|
2
|
+
export { DEFAULT_OPENLDAP_CONFIG, DEFAULT_OPENLDAP_PASSWORD, DEFAULT_USERS as DEFAULT_OPENLDAP_USERS, buildBindDn, buildUsersDn, buildGroupsDn, } from "./constants.js";
|
|
3
|
+
export type { OpenLDAPDeploymentOptions, OpenLDAPDeploymentConfig, OpenLDAPBindConfig, } from "./types.js";
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/openldap/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,IAAI,sBAAsB,EACvC,WAAW,EACX,YAAY,EACZ,aAAa,GACd,MAAM,gBAAgB,CAAC;AACxB,YAAY,EACV,yBAAyB,EACzB,wBAAwB,EACxB,kBAAkB,GACnB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type OpenLDAPDeploymentOptions = {
|
|
2
|
+
releaseName?: string;
|
|
3
|
+
adminUser?: string;
|
|
4
|
+
adminPassword?: string;
|
|
5
|
+
baseDn?: string;
|
|
6
|
+
usersOu?: string;
|
|
7
|
+
groupsOu?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
imageRepository?: string;
|
|
10
|
+
imageTag?: string;
|
|
11
|
+
valuesFile?: string;
|
|
12
|
+
seedLdifFile?: string;
|
|
13
|
+
};
|
|
14
|
+
export type OpenLDAPDeploymentConfig = {
|
|
15
|
+
namespace: string;
|
|
16
|
+
releaseName: string;
|
|
17
|
+
adminUser: string;
|
|
18
|
+
adminPassword: string;
|
|
19
|
+
baseDn: string;
|
|
20
|
+
usersOu: string;
|
|
21
|
+
groupsOu: string;
|
|
22
|
+
port: number;
|
|
23
|
+
imageRepository: string;
|
|
24
|
+
imageTag: string;
|
|
25
|
+
seedLdifFile: string;
|
|
26
|
+
};
|
|
27
|
+
export type OpenLDAPBindConfig = {
|
|
28
|
+
bindDn: string;
|
|
29
|
+
bindSecret: string;
|
|
30
|
+
usersDn: string;
|
|
31
|
+
groupsDn: string;
|
|
32
|
+
baseDn: string;
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/deployment/openldap/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
import { UIhelper } from "./ui-helper.js";
|
|
2
|
-
import type { Browser, Page, TestInfo } from "@playwright/test";
|
|
2
|
+
import type { Browser, BrowserContext, Page, TestInfo } from "@playwright/test";
|
|
3
|
+
/**
|
|
4
|
+
* Where a GitHub storage state is cached, and the lock that serialises access to it.
|
|
5
|
+
*
|
|
6
|
+
* The name used to be a bare relative `authState_<user>.json`, resolved against
|
|
7
|
+
* `process.cwd()` — which the worker fixture sets to the workspace's `e2e-tests`
|
|
8
|
+
* directory, the same value for every project in that workspace. So every lane and
|
|
9
|
+
* every worker shared one file with no lock: a reader could land mid-write and fail on
|
|
10
|
+
* truncated JSON, and a stale file could survive into a run that needed a fresh login.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately still one file per *user*, not per project. Scoping it per project was
|
|
13
|
+
* the obvious fix and is the wrong one: `logintoGithub` derives its 2FA code from a
|
|
14
|
+
* single shared TOTP secret, so two lanes logging in inside the same 30-second window
|
|
15
|
+
* submit the identical code and GitHub rejects the second — a failure this file already
|
|
16
|
+
* has retry handling for. Sharing the session is the point of caching it; what was
|
|
17
|
+
* missing was making concurrent access safe, which is what the lock and the atomic
|
|
18
|
+
* write below do. RHDH cookies from another lane are harmless: each lane's RHDH lives
|
|
19
|
+
* on its own namespace hostname, so they are never sent anywhere they matter.
|
|
20
|
+
*/
|
|
21
|
+
export declare function githubSessionFile(userid: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Cookies from a stored session, or `undefined` when there is nothing usable.
|
|
24
|
+
*
|
|
25
|
+
* A cached session is an optimisation, so a missing, truncated or malformed file must
|
|
26
|
+
* fall through to a full login rather than fail the test. Before this, a partially
|
|
27
|
+
* written file threw out of `JSON.parse` and read as a plugin failure.
|
|
28
|
+
*/
|
|
29
|
+
export type StoredCookies = Parameters<BrowserContext["addCookies"]>[0];
|
|
30
|
+
export declare function readStoredCookies(file: string): StoredCookies | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Writes the storage state so a concurrent reader never sees a partial file.
|
|
33
|
+
*
|
|
34
|
+
* `storageState({ path })` writes in place, so a reader can observe the file between
|
|
35
|
+
* create and write. Writing to a temp name and renaming makes the appearance of the
|
|
36
|
+
* final path atomic. The temp name carries the pid because Playwright workers are
|
|
37
|
+
* separate processes, and it is removed even when the write fails so failed runs do
|
|
38
|
+
* not litter the workspace.
|
|
39
|
+
*/
|
|
40
|
+
export declare function writeStorageStateAtomically(page: Page, file: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Runs `fn` with exclusive access to the session file, across workers and lanes.
|
|
43
|
+
*
|
|
44
|
+
* Without this the first lane to start would not have finished writing before the
|
|
45
|
+
* others decided there was no session and each began its own login — which is the
|
|
46
|
+
* TOTP collision described above, not merely wasted work. The lock target is created
|
|
47
|
+
* rather than assumed: `proper-lockfile` needs an existing path, and the session file
|
|
48
|
+
* itself does not exist on the run that has to create it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function withGithubSessionLock<T>(file: string, fn: () => Promise<T>): Promise<T>;
|
|
51
|
+
/**
|
|
52
|
+
* Creates the shared GitHub session if it is missing, and says which happened.
|
|
53
|
+
*
|
|
54
|
+
* Only creation needs to be exclusive: it drives a real GitHub sign-in whose 2FA
|
|
55
|
+
* code comes from one shared TOTP secret, so two lanes doing it inside the same
|
|
56
|
+
* 30-second window submit the identical code and the second is rejected. Reusing
|
|
57
|
+
* an existing session is just cookies plus a Sign In click against a different
|
|
58
|
+
* namespace host, and serialising that behind the lock made every lane queue for
|
|
59
|
+
* a sign-in it did not need — long enough that a waiter could exhaust Playwright's
|
|
60
|
+
* default test timeout before the lock's own retries ran out. `test.setTimeout`
|
|
61
|
+
* is raised inside the login itself, which is precisely the path a waiter is not on.
|
|
62
|
+
*
|
|
63
|
+
* The re-read inside the lock is what keeps that safe: whoever held the lock before
|
|
64
|
+
* us has almost certainly just created the session, and logging in again would be
|
|
65
|
+
* the same collision the lock exists to prevent.
|
|
66
|
+
*/
|
|
67
|
+
export declare function ensureGithubSession(file: string, create: () => Promise<void>): Promise<"reused" | "created">;
|
|
3
68
|
export declare class LoginHelper {
|
|
4
69
|
page: Page;
|
|
5
70
|
uiHelper: UIhelper;
|
|
@@ -8,8 +73,14 @@ export declare class LoginHelper {
|
|
|
8
73
|
signOut(): Promise<void>;
|
|
9
74
|
private logintoGithub;
|
|
10
75
|
logintoKeycloak(popup: Page, userid: string, password: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Sign in via Keycloak popup. Supports both OIDC ("Sign In") and the
|
|
78
|
+
* community keycloak provider ("Sign in using Keycloak").
|
|
79
|
+
*/
|
|
11
80
|
loginAsKeycloakUser(userid?: string, password?: string): Promise<void>;
|
|
12
81
|
loginAsGithubUser(userid?: string): Promise<void>;
|
|
82
|
+
private _reuseGithubSession;
|
|
83
|
+
private _createGithubSession;
|
|
13
84
|
checkAndReauthorizeGithubApp(): Promise<void>;
|
|
14
85
|
private handleGithubPopupReauth;
|
|
15
86
|
googleSignIn(email: string): Promise<void>;
|
|
@@ -26,6 +97,6 @@ export declare class LoginHelper {
|
|
|
26
97
|
}
|
|
27
98
|
export declare function setupBrowser(browser: Browser, testInfo: TestInfo): Promise<{
|
|
28
99
|
page: Page;
|
|
29
|
-
context:
|
|
100
|
+
context: BrowserContext;
|
|
30
101
|
}>;
|
|
31
102
|
//# sourceMappingURL=common.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAOhF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAExE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAQzE;AAED;;;;;;;;GAQG;AACH,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAC3C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAYZ;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAC1B,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CAQ/B;AAED,qBAAa,WAAW;IACtB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;gBAEP,IAAI,EAAE,IAAI;IAKhB,YAAY;IAcZ,OAAO;YAMC,aAAa;IA2CrB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAOnE;;;OAGG;IACG,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAoBxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;YAa3C,mBAAmB;YAwCnB,oBAAoB;IAW5B,4BAA4B;YASpB,uBAAuB;IAgB/B,YAAY,CAAC,KAAK,EAAE,MAAM;IA2B1B,2BAA2B,CAAC,KAAK,UAAQ;IAU/C,mBAAmB,IAAI,MAAM;IAIvB,mBAAmB;IAgBzB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAevC,eAAe,IAAI,MAAM;IAKnB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;YAqCxC,sBAAsB;IAoD9B,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAYjE,2BAA2B,CAC/B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM;IAYb,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CA2C7D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;;;GAWtE"}
|
|
@@ -4,7 +4,108 @@ import { test, expect } from "@playwright/test";
|
|
|
4
4
|
import { SETTINGS_PAGE_COMPONENTS } from "../page-objects/page-obj.js";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import * as fs from "fs";
|
|
7
|
+
import lockfile from "proper-lockfile";
|
|
7
8
|
import { DEFAULT_USERS } from "../../deployment/keycloak/constants.js";
|
|
9
|
+
/**
|
|
10
|
+
* Where a GitHub storage state is cached, and the lock that serialises access to it.
|
|
11
|
+
*
|
|
12
|
+
* The name used to be a bare relative `authState_<user>.json`, resolved against
|
|
13
|
+
* `process.cwd()` — which the worker fixture sets to the workspace's `e2e-tests`
|
|
14
|
+
* directory, the same value for every project in that workspace. So every lane and
|
|
15
|
+
* every worker shared one file with no lock: a reader could land mid-write and fail on
|
|
16
|
+
* truncated JSON, and a stale file could survive into a run that needed a fresh login.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately still one file per *user*, not per project. Scoping it per project was
|
|
19
|
+
* the obvious fix and is the wrong one: `logintoGithub` derives its 2FA code from a
|
|
20
|
+
* single shared TOTP secret, so two lanes logging in inside the same 30-second window
|
|
21
|
+
* submit the identical code and GitHub rejects the second — a failure this file already
|
|
22
|
+
* has retry handling for. Sharing the session is the point of caching it; what was
|
|
23
|
+
* missing was making concurrent access safe, which is what the lock and the atomic
|
|
24
|
+
* write below do. RHDH cookies from another lane are harmless: each lane's RHDH lives
|
|
25
|
+
* on its own namespace hostname, so they are never sent anywhere they matter.
|
|
26
|
+
*/
|
|
27
|
+
export function githubSessionFile(userid) {
|
|
28
|
+
const safe = String(userid).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
29
|
+
return path.resolve(`authState_${safe}.json`);
|
|
30
|
+
}
|
|
31
|
+
export function readStoredCookies(file) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
34
|
+
const cookies = parsed?.cookies;
|
|
35
|
+
return Array.isArray(cookies) && cookies.length > 0 ? cookies : undefined;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Writes the storage state so a concurrent reader never sees a partial file.
|
|
43
|
+
*
|
|
44
|
+
* `storageState({ path })` writes in place, so a reader can observe the file between
|
|
45
|
+
* create and write. Writing to a temp name and renaming makes the appearance of the
|
|
46
|
+
* final path atomic. The temp name carries the pid because Playwright workers are
|
|
47
|
+
* separate processes, and it is removed even when the write fails so failed runs do
|
|
48
|
+
* not litter the workspace.
|
|
49
|
+
*/
|
|
50
|
+
export async function writeStorageStateAtomically(page, file) {
|
|
51
|
+
const pending = `${file}.${process.pid}.tmp`;
|
|
52
|
+
try {
|
|
53
|
+
await page.context().storageState({ path: pending });
|
|
54
|
+
fs.renameSync(pending, file);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
fs.rmSync(pending, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs `fn` with exclusive access to the session file, across workers and lanes.
|
|
62
|
+
*
|
|
63
|
+
* Without this the first lane to start would not have finished writing before the
|
|
64
|
+
* others decided there was no session and each began its own login — which is the
|
|
65
|
+
* TOTP collision described above, not merely wasted work. The lock target is created
|
|
66
|
+
* rather than assumed: `proper-lockfile` needs an existing path, and the session file
|
|
67
|
+
* itself does not exist on the run that has to create it.
|
|
68
|
+
*/
|
|
69
|
+
export async function withGithubSessionLock(file, fn) {
|
|
70
|
+
const target = `${file}.lock-target`;
|
|
71
|
+
fs.writeFileSync(target, "", { flag: "a" });
|
|
72
|
+
const release = await lockfile.lock(target, {
|
|
73
|
+
retries: { retries: 60, minTimeout: 1_000 },
|
|
74
|
+
stale: 300_000,
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
return await fn();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await release();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Creates the shared GitHub session if it is missing, and says which happened.
|
|
85
|
+
*
|
|
86
|
+
* Only creation needs to be exclusive: it drives a real GitHub sign-in whose 2FA
|
|
87
|
+
* code comes from one shared TOTP secret, so two lanes doing it inside the same
|
|
88
|
+
* 30-second window submit the identical code and the second is rejected. Reusing
|
|
89
|
+
* an existing session is just cookies plus a Sign In click against a different
|
|
90
|
+
* namespace host, and serialising that behind the lock made every lane queue for
|
|
91
|
+
* a sign-in it did not need — long enough that a waiter could exhaust Playwright's
|
|
92
|
+
* default test timeout before the lock's own retries ran out. `test.setTimeout`
|
|
93
|
+
* is raised inside the login itself, which is precisely the path a waiter is not on.
|
|
94
|
+
*
|
|
95
|
+
* The re-read inside the lock is what keeps that safe: whoever held the lock before
|
|
96
|
+
* us has almost certainly just created the session, and logging in again would be
|
|
97
|
+
* the same collision the lock exists to prevent.
|
|
98
|
+
*/
|
|
99
|
+
export async function ensureGithubSession(file, create) {
|
|
100
|
+
if (readStoredCookies(file))
|
|
101
|
+
return "reused";
|
|
102
|
+
return await withGithubSessionLock(file, async () => {
|
|
103
|
+
if (readStoredCookies(file))
|
|
104
|
+
return "reused";
|
|
105
|
+
await create();
|
|
106
|
+
return "created";
|
|
107
|
+
});
|
|
108
|
+
}
|
|
8
109
|
export class LoginHelper {
|
|
9
110
|
page;
|
|
10
111
|
uiHelper;
|
|
@@ -61,55 +162,74 @@ export class LoginHelper {
|
|
|
61
162
|
await popup.locator("#password").fill(password);
|
|
62
163
|
await popup.locator("#kc-login").click();
|
|
63
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Sign in via Keycloak popup. Supports both OIDC ("Sign In") and the
|
|
167
|
+
* community keycloak provider ("Sign in using Keycloak").
|
|
168
|
+
*/
|
|
64
169
|
async loginAsKeycloakUser(userid = DEFAULT_USERS[0].username, password = DEFAULT_USERS[0].password) {
|
|
65
170
|
await this.page.goto("/");
|
|
66
171
|
await this.uiHelper.waitForLoad(240000);
|
|
67
172
|
const popupPromise = this.page.waitForEvent("popup");
|
|
68
|
-
|
|
173
|
+
const keycloakProviderBtn = this.page.getByRole("button", {
|
|
174
|
+
name: /sign in using keycloak/i,
|
|
175
|
+
});
|
|
176
|
+
if (await keycloakProviderBtn.isVisible().catch(() => false)) {
|
|
177
|
+
await keycloakProviderBtn.click();
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
await this.uiHelper.clickButton("Sign In");
|
|
181
|
+
}
|
|
69
182
|
const popup = await popupPromise;
|
|
70
183
|
await this.logintoKeycloak(popup, userid, password);
|
|
71
|
-
await this.page.waitForSelector("nav a", { timeout:
|
|
184
|
+
await this.page.waitForSelector("nav a", { timeout: 30_000 });
|
|
72
185
|
}
|
|
73
186
|
async loginAsGithubUser(userid = process.env.VAULT_GH_USER_ID) {
|
|
74
|
-
const sessionFileName =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
await this.
|
|
80
|
-
console.log(`Reusing existing authentication state for user: ${userid}`);
|
|
81
|
-
await this.page.goto("/");
|
|
82
|
-
await this.uiHelper.waitForLoad(12000);
|
|
83
|
-
await this.uiHelper.clickButton("Sign In");
|
|
84
|
-
// Wait for either: sidebar appears (auto-login) or popup opens (needs auth)
|
|
85
|
-
const navPromise = this.page
|
|
86
|
-
.waitForSelector("nav a", { timeout: 15_000 })
|
|
87
|
-
.then(() => "nav")
|
|
88
|
-
.catch(() => null);
|
|
89
|
-
const popupPromise = this.page
|
|
90
|
-
.waitForEvent("popup", { timeout: 15_000 })
|
|
91
|
-
.then((popup) => ({ popup }))
|
|
92
|
-
.catch(() => null);
|
|
93
|
-
const result = await Promise.race([navPromise, popupPromise]);
|
|
94
|
-
if (result === null) {
|
|
95
|
-
throw new Error("GitHub login failed: neither sidebar nor popup appeared after Sign In — session file may be stale");
|
|
96
|
-
}
|
|
97
|
-
if (typeof result === "object" && "popup" in result) {
|
|
98
|
-
// Popup opened — handle reauthorization
|
|
99
|
-
await this.handleGithubPopupReauth(result.popup);
|
|
100
|
-
}
|
|
187
|
+
const sessionFileName = githubSessionFile(userid);
|
|
188
|
+
const outcome = await ensureGithubSession(sessionFileName, () => this._createGithubSession(userid, sessionFileName));
|
|
189
|
+
// Creating already left this page signed in; replaying the reuse path would
|
|
190
|
+
// click Sign In a second time against a session that is already live.
|
|
191
|
+
if (outcome === "reused") {
|
|
192
|
+
await this._reuseGithubSession(userid, sessionFileName);
|
|
101
193
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await this.uiHelper.clickButton("Sign In");
|
|
108
|
-
await this.checkAndReauthorizeGithubApp();
|
|
109
|
-
await this.page.waitForSelector("nav a", { timeout: 10_000 });
|
|
110
|
-
await this.page.context().storageState({ path: sessionFileName });
|
|
111
|
-
console.log(`Authentication state saved for user: ${userid}`);
|
|
194
|
+
}
|
|
195
|
+
async _reuseGithubSession(userid, sessionFileName) {
|
|
196
|
+
const cookies = readStoredCookies(sessionFileName);
|
|
197
|
+
if (!cookies) {
|
|
198
|
+
throw new Error(`GitHub session file for ${userid} disappeared between the check and the read: ${sessionFileName}`);
|
|
112
199
|
}
|
|
200
|
+
// Load and reuse existing authentication state
|
|
201
|
+
await this.page.context().addCookies(cookies);
|
|
202
|
+
console.log(`Reusing existing authentication state for user: ${userid}`);
|
|
203
|
+
await this.page.goto("/");
|
|
204
|
+
await this.uiHelper.waitForLoad(12000);
|
|
205
|
+
await this.uiHelper.clickButton("Sign In");
|
|
206
|
+
// Wait for either: sidebar appears (auto-login) or popup opens (needs auth)
|
|
207
|
+
const navPromise = this.page
|
|
208
|
+
.waitForSelector("nav a", { timeout: 15_000 })
|
|
209
|
+
.then(() => "nav")
|
|
210
|
+
.catch(() => null);
|
|
211
|
+
const popupPromise = this.page
|
|
212
|
+
.waitForEvent("popup", { timeout: 15_000 })
|
|
213
|
+
.then((popup) => ({ popup }))
|
|
214
|
+
.catch(() => null);
|
|
215
|
+
const result = await Promise.race([navPromise, popupPromise]);
|
|
216
|
+
if (result === null) {
|
|
217
|
+
throw new Error("GitHub login failed: neither sidebar nor popup appeared after Sign In — session file may be stale");
|
|
218
|
+
}
|
|
219
|
+
if (typeof result === "object" && "popup" in result) {
|
|
220
|
+
// Popup opened — handle reauthorization
|
|
221
|
+
await this.handleGithubPopupReauth(result.popup);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async _createGithubSession(userid, sessionFileName) {
|
|
225
|
+
await this.logintoGithub(userid);
|
|
226
|
+
await this.page.goto("/");
|
|
227
|
+
await this.uiHelper.waitForLoad(240000);
|
|
228
|
+
await this.uiHelper.clickButton("Sign In");
|
|
229
|
+
await this.checkAndReauthorizeGithubApp();
|
|
230
|
+
await this.page.waitForSelector("nav a", { timeout: 10_000 });
|
|
231
|
+
await writeStorageStateAtomically(this.page, sessionFileName);
|
|
232
|
+
console.log(`Authentication state saved for user: ${userid}`);
|
|
113
233
|
}
|
|
114
234
|
async checkAndReauthorizeGithubApp() {
|
|
115
235
|
await new Promise((resolve) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github-session.test.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/github-session.test.ts"],"names":[],"mappings":""}
|