@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.
Files changed (79) hide show
  1. package/dist/deployment/keycloak/deployment.d.ts +28 -1
  2. package/dist/deployment/keycloak/deployment.d.ts.map +1 -1
  3. package/dist/deployment/keycloak/deployment.js +210 -0
  4. package/dist/deployment/keycloak/index.d.ts +2 -1
  5. package/dist/deployment/keycloak/index.d.ts.map +1 -1
  6. package/dist/deployment/keycloak/index.js +1 -0
  7. package/dist/deployment/keycloak/types.d.ts +35 -0
  8. package/dist/deployment/keycloak/types.d.ts.map +1 -1
  9. package/dist/deployment/openldap/config/seed.ldif +102 -0
  10. package/dist/deployment/openldap/constants.d.ts +46 -0
  11. package/dist/deployment/openldap/constants.d.ts.map +1 -0
  12. package/dist/deployment/openldap/constants.js +65 -0
  13. package/dist/deployment/openldap/deployment.d.ts +31 -0
  14. package/dist/deployment/openldap/deployment.d.ts.map +1 -0
  15. package/dist/deployment/openldap/deployment.js +219 -0
  16. package/dist/deployment/openldap/index.d.ts +4 -0
  17. package/dist/deployment/openldap/index.d.ts.map +1 -0
  18. package/dist/deployment/openldap/index.js +2 -0
  19. package/dist/deployment/openldap/types.d.ts +34 -0
  20. package/dist/deployment/openldap/types.d.ts.map +1 -0
  21. package/dist/deployment/openldap/types.js +1 -0
  22. package/dist/playwright/global-setup.d.ts +5 -0
  23. package/dist/playwright/global-setup.d.ts.map +1 -1
  24. package/dist/playwright/global-setup.js +2 -4
  25. package/dist/playwright/helpers/common.d.ts +73 -2
  26. package/dist/playwright/helpers/common.d.ts.map +1 -1
  27. package/dist/playwright/helpers/common.js +159 -39
  28. package/dist/playwright/helpers/github-session.test.d.ts +2 -0
  29. package/dist/playwright/helpers/github-session.test.d.ts.map +1 -0
  30. package/dist/playwright/helpers/github-session.test.js +199 -0
  31. package/dist/playwright/tests/global-setup.test.d.ts +2 -0
  32. package/dist/playwright/tests/global-setup.test.d.ts.map +1 -0
  33. package/dist/playwright/tests/global-setup.test.js +68 -0
  34. package/dist/secrets/bitwarden.d.ts +34 -0
  35. package/dist/secrets/bitwarden.d.ts.map +1 -0
  36. package/dist/secrets/bitwarden.js +193 -0
  37. package/dist/secrets/cli.d.ts +11 -0
  38. package/dist/secrets/cli.d.ts.map +1 -0
  39. package/dist/secrets/cli.js +98 -0
  40. package/dist/secrets/command.d.ts +15 -0
  41. package/dist/secrets/command.d.ts.map +1 -0
  42. package/dist/secrets/command.js +30 -0
  43. package/dist/secrets/config.d.ts +33 -0
  44. package/dist/secrets/config.d.ts.map +1 -0
  45. package/dist/secrets/config.js +156 -0
  46. package/dist/secrets/environment.d.ts +10 -0
  47. package/dist/secrets/environment.d.ts.map +1 -0
  48. package/dist/secrets/environment.js +53 -0
  49. package/dist/secrets/exec.d.ts +18 -0
  50. package/dist/secrets/exec.d.ts.map +1 -0
  51. package/dist/secrets/exec.js +50 -0
  52. package/dist/secrets/index.d.ts +5 -0
  53. package/dist/secrets/index.d.ts.map +1 -0
  54. package/dist/secrets/index.js +4 -0
  55. package/dist/secrets/tests/bitwarden.integration.test.d.ts +2 -0
  56. package/dist/secrets/tests/bitwarden.integration.test.d.ts.map +1 -0
  57. package/dist/secrets/tests/bitwarden.integration.test.js +50 -0
  58. package/dist/secrets/tests/bitwarden.test.d.ts +2 -0
  59. package/dist/secrets/tests/bitwarden.test.d.ts.map +1 -0
  60. package/dist/secrets/tests/bitwarden.test.js +418 -0
  61. package/dist/secrets/tests/cli.integration.test.d.ts +2 -0
  62. package/dist/secrets/tests/cli.integration.test.d.ts.map +1 -0
  63. package/dist/secrets/tests/cli.integration.test.js +70 -0
  64. package/dist/secrets/tests/cli.test.d.ts +2 -0
  65. package/dist/secrets/tests/cli.test.d.ts.map +1 -0
  66. package/dist/secrets/tests/cli.test.js +42 -0
  67. package/dist/secrets/tests/config.test.d.ts +2 -0
  68. package/dist/secrets/tests/config.test.d.ts.map +1 -0
  69. package/dist/secrets/tests/config.test.js +105 -0
  70. package/dist/secrets/tests/environment.test.d.ts +2 -0
  71. package/dist/secrets/tests/environment.test.d.ts.map +1 -0
  72. package/dist/secrets/tests/environment.test.js +119 -0
  73. package/dist/secrets/tests/exec.test.d.ts +2 -0
  74. package/dist/secrets/tests/exec.test.d.ts.map +1 -0
  75. package/dist/secrets/tests/exec.test.js +87 -0
  76. package/package.json +13 -2
  77. package/dist/utils/vault.d.ts +0 -16
  78. package/dist/utils/vault.d.ts.map +0 -1
  79. package/dist/utils/vault.js +0 -94
@@ -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,2 @@
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";
@@ -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 {};
@@ -4,4 +4,9 @@
4
4
  */
5
5
  import { type FullConfig } from "@playwright/test";
6
6
  export default function globalSetup(config: FullConfig): Promise<void>;
7
+ /**
8
+ * Loads .env files from each project's e2e-tests directory.
9
+ * Uses `override: true` so local .env values take priority over inherited values.
10
+ */
11
+ export declare function loadDotenvFromProjects(config: FullConfig): void;
7
12
  //# sourceMappingURL=global-setup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"global-setup.d.ts","sourceRoot":"","sources":["../../src/playwright/global-setup.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA8EnD,wBAA8B,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ3E"}
1
+ {"version":3,"file":"global-setup.d.ts","sourceRoot":"","sources":["../../src/playwright/global-setup.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA6EnD,wBAA8B,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAO3E;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAS/D"}
@@ -9,7 +9,6 @@ import { $ } from "../utils/bash.js";
9
9
  import { KeycloakHelper } from "../deployment/keycloak/index.js";
10
10
  import { installRHDHOperator } from "../deployment/rhdh/operator-setup.js";
11
11
  import { DEFAULT_KEYCLOAK_CONFIG, DEFAULT_RHDH_CLIENT, DEFAULT_USERS, } from "../deployment/keycloak/constants.js";
12
- import { loadLocalVaultSecrets } from "../utils/vault.js";
13
12
  const REQUIRED_BINARIES = ["oc", "kubectl", "helm"];
14
13
  async function checkRequiredBinaries() {
15
14
  const missingBinaries = [];
@@ -65,7 +64,6 @@ async function deployKeycloak() {
65
64
  export default async function globalSetup(config) {
66
65
  console.log("Running global setup...");
67
66
  await checkRequiredBinaries();
68
- await loadLocalVaultSecrets();
69
67
  loadDotenvFromProjects(config);
70
68
  await setClusterRouterBaseEnv();
71
69
  await Promise.all([installRHDHOperator(), deployKeycloak()]);
@@ -73,9 +71,9 @@ export default async function globalSetup(config) {
73
71
  }
74
72
  /**
75
73
  * Loads .env files from each project's e2e-tests directory.
76
- * Uses `override: true` so local .env values take priority over Vault secrets.
74
+ * Uses `override: true` so local .env values take priority over inherited values.
77
75
  */
78
- function loadDotenvFromProjects(config) {
76
+ export function loadDotenvFromProjects(config) {
79
77
  const seen = new Set();
80
78
  for (const project of config.projects) {
81
79
  // testDir points to e2e-tests/tests, go up one level to e2e-tests/
@@ -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: import("playwright-core").BrowserContext;
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;AAMhE,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;IAO7D,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAWxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;IAoDnD,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"}
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
- await this.uiHelper.clickButton("Sign In");
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: 10_000 });
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 = `authState_${userid}.json`;
75
- // Check if a session file for this specific user already exists
76
- if (fs.existsSync(sessionFileName)) {
77
- // Load and reuse existing authentication state
78
- const cookies = JSON.parse(fs.readFileSync(sessionFileName, "utf-8")).cookies;
79
- await this.page.context().addCookies(cookies);
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
- else {
103
- // Perform login if no session file exists, then save the state
104
- await this.logintoGithub(userid);
105
- await this.page.goto("/");
106
- await this.uiHelper.waitForLoad(240000);
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=github-session.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-session.test.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/github-session.test.ts"],"names":[],"mappings":""}