@foss.global/forgefixtures 0.2.0

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 (65) hide show
  1. package/.smartconfig.json +49 -0
  2. package/changelog.md +17 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/classes.certificateauthority.d.ts +22 -0
  6. package/dist_ts/classes.certificateauthority.js +93 -0
  7. package/dist_ts/classes.containerlifecycle.d.ts +88 -0
  8. package/dist_ts/classes.containerlifecycle.js +383 -0
  9. package/dist_ts/classes.giteafixture.d.ts +41 -0
  10. package/dist_ts/classes.giteafixture.js +182 -0
  11. package/dist_ts/classes.giteaseed.d.ts +13 -0
  12. package/dist_ts/classes.giteaseed.js +432 -0
  13. package/dist_ts/classes.gitlabfixture.d.ts +49 -0
  14. package/dist_ts/classes.gitlabfixture.js +237 -0
  15. package/dist_ts/classes.gitlabseed.d.ts +13 -0
  16. package/dist_ts/classes.gitlabseed.js +466 -0
  17. package/dist_ts/classes.httpclient.d.ts +53 -0
  18. package/dist_ts/classes.httpclient.js +116 -0
  19. package/dist_ts/classes.reaper.d.ts +25 -0
  20. package/dist_ts/classes.reaper.js +102 -0
  21. package/dist_ts/classes.tlsterminator.d.ts +21 -0
  22. package/dist_ts/classes.tlsterminator.js +131 -0
  23. package/dist_ts/constants.d.ts +22 -0
  24. package/dist_ts/constants.js +30 -0
  25. package/dist_ts/giteaseed.default.d.ts +10 -0
  26. package/dist_ts/giteaseed.default.js +87 -0
  27. package/dist_ts/gitlabseed.default.d.ts +12 -0
  28. package/dist_ts/gitlabseed.default.js +84 -0
  29. package/dist_ts/index.d.ts +17 -0
  30. package/dist_ts/index.js +17 -0
  31. package/dist_ts/interfaces.d.ts +92 -0
  32. package/dist_ts/interfaces.giteaseed.d.ts +182 -0
  33. package/dist_ts/interfaces.giteaseed.js +2 -0
  34. package/dist_ts/interfaces.gitlabseed.d.ts +183 -0
  35. package/dist_ts/interfaces.gitlabseed.js +2 -0
  36. package/dist_ts/interfaces.js +2 -0
  37. package/dist_ts/ownership.d.ts +29 -0
  38. package/dist_ts/ownership.js +140 -0
  39. package/dist_ts/plugins.d.ts +13 -0
  40. package/dist_ts/plugins.js +18 -0
  41. package/dist_ts/responses.d.ts +7 -0
  42. package/dist_ts/responses.js +30 -0
  43. package/license.md +21 -0
  44. package/package.json +65 -0
  45. package/readme.md +206 -0
  46. package/ts/00_commitinfo_data.ts +8 -0
  47. package/ts/classes.certificateauthority.ts +117 -0
  48. package/ts/classes.containerlifecycle.ts +432 -0
  49. package/ts/classes.giteafixture.ts +205 -0
  50. package/ts/classes.giteaseed.ts +486 -0
  51. package/ts/classes.gitlabfixture.ts +258 -0
  52. package/ts/classes.gitlabseed.ts +502 -0
  53. package/ts/classes.httpclient.ts +156 -0
  54. package/ts/classes.reaper.ts +126 -0
  55. package/ts/classes.tlsterminator.ts +136 -0
  56. package/ts/constants.ts +35 -0
  57. package/ts/giteaseed.default.ts +88 -0
  58. package/ts/gitlabseed.default.ts +86 -0
  59. package/ts/index.ts +17 -0
  60. package/ts/interfaces.giteaseed.ts +130 -0
  61. package/ts/interfaces.gitlabseed.ts +135 -0
  62. package/ts/interfaces.ts +94 -0
  63. package/ts/ownership.ts +160 -0
  64. package/ts/plugins.ts +23 -0
  65. package/ts/responses.ts +33 -0
@@ -0,0 +1,502 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as responses from './responses.js';
3
+ import type { GitlabFixture } from './classes.gitlabfixture.js';
4
+ import type { ForgeFixtureHttpClient } from './classes.httpclient.js';
5
+ import type {
6
+ IGitlabSeedFile,
7
+ IGitlabSeedIssue,
8
+ IGitlabSeedManifest,
9
+ IGitlabSeedManifestAuthor,
10
+ IGitlabSeedManifestProject,
11
+ IGitlabSeedMergeRequest,
12
+ IGitlabSeedProject,
13
+ IGitlabSeedSpec,
14
+ TGitlabAccessLevel,
15
+ TGitlabVisibility,
16
+ } from './interfaces.gitlabseed.js';
17
+
18
+ const defaultBranch = 'main';
19
+ const pathPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/;
20
+ const colorPattern = /^#[0-9a-f]{6}$/;
21
+ const accessLevels: Record<TGitlabAccessLevel, number> = { guest: 10, reporter: 20, developer: 30, maintainer: 40, owner: 50 };
22
+ const userDeletionTimeoutMs = 5 * 60 * 1000;
23
+ const projectAccessTimeoutMs = 2 * 60 * 1000;
24
+
25
+ interface ISeededItemRef {
26
+ key: string;
27
+ kind: 'issue' | 'mergeRequest';
28
+ iid: number;
29
+ }
30
+
31
+ interface ISeededProjectRefs {
32
+ spec: IGitlabSeedProject;
33
+ id: number;
34
+ labels: Map<string, number>;
35
+ milestones: Map<string, { id: number; iid: number; state: 'active' | 'closed' }>;
36
+ tags: Array<{ name: string; commitSha: string }>;
37
+ items: ISeededItemRef[];
38
+ releases: Array<{ tagName: string; name: string }>;
39
+ /** Users already seen to have access to the project. */
40
+ accessibleTo: Set<string>;
41
+ }
42
+
43
+ const sleep = (msArg: number) => new Promise<void>((resolveArg) => setTimeout(resolveArg, msArg));
44
+
45
+ /** Rejects inconsistent specs before the first mutation. */
46
+ export const validateGitlabSeedSpec = (specArg: IGitlabSeedSpec): void => {
47
+ const fail = (messageArg: string): never => {
48
+ throw new TypeError(`Invalid GitLab seed spec: ${messageArg}`);
49
+ };
50
+ const unique = (valuesArg: string[], whatArg: string) => {
51
+ const seen = new Set<string>();
52
+ for (const value of valuesArg) {
53
+ if (seen.has(value.toLowerCase())) fail(`duplicate ${whatArg} "${value}"`);
54
+ seen.add(value.toLowerCase());
55
+ }
56
+ };
57
+ const users = new Set(specArg.users.map((userArg) => userArg.username));
58
+ for (const username of users) if (!pathPattern.test(username) || username.toLowerCase() === 'root') fail(`malformed or reserved username "${username}"`);
59
+ const groups = new Set<string>();
60
+ for (const group of specArg.groups) {
61
+ if (!pathPattern.test(group.path)) fail(`malformed group path "${group.path}"`);
62
+ if (group.parent !== undefined && !groups.has(group.parent)) fail(`group "${group.path}" names a parent that is not declared before it`);
63
+ const fullPath = group.parent === undefined ? group.path : `${group.parent}/${group.path}`;
64
+ if (groups.has(fullPath)) fail(`duplicate group "${fullPath}"`);
65
+ groups.add(fullPath);
66
+ for (const member of group.members ?? []) if (!users.has(member.username)) fail(`group "${fullPath}" references unknown user "${member.username}"`);
67
+ }
68
+ unique([...users, ...[...groups].filter((pathArg) => !pathArg.includes('/'))], 'top-level namespace');
69
+ const deleted = new Set(specArg.deleteUsers ?? []);
70
+ for (const username of deleted) if (!users.has(username)) fail(`cannot delete unknown user "${username}"`);
71
+ unique(specArg.projects.map((projectArg) => `${projectArg.namespace}/${projectArg.path}`), 'project');
72
+ for (const project of specArg.projects) {
73
+ const where = `${project.namespace}/${project.path}`;
74
+ if (!pathPattern.test(project.path)) fail(`malformed project path "${where}"`);
75
+ if (!groups.has(project.namespace) && !users.has(project.namespace)) fail(`${where} has an unknown namespace`);
76
+ if (deleted.has(project.namespace)) fail(`${where} belongs to a user scheduled for deletion`);
77
+ const knownUser = (usernameArg: string) => {
78
+ if (!users.has(usernameArg)) fail(`${where} references unknown user "${usernameArg}"`);
79
+ };
80
+ for (const member of project.members ?? []) knownUser(member.username);
81
+ const filePaths = (project.files ?? []).map((fileArg) => fileArg.path);
82
+ unique(filePaths, `${where} file`);
83
+ if (filePaths.includes('README.md')) fail(`${where} cannot seed README.md; initialisation creates it`);
84
+ const branches = new Set([defaultBranch]);
85
+ for (const branch of project.branches ?? []) {
86
+ if (branches.has(branch.name)) fail(`${where} declares branch "${branch.name}" twice`);
87
+ if (!branches.has(branch.from ?? defaultBranch)) fail(`${where} branch "${branch.name}" starts from an undeclared branch`);
88
+ branches.add(branch.name);
89
+ }
90
+ const labels = new Set((project.labels ?? []).map((labelArg) => {
91
+ if (!colorPattern.test(labelArg.color)) fail(`${where} label "${labelArg.name}" needs a lowercase #rrggbb color`);
92
+ if (labelArg.name.includes(',')) fail(`${where} label "${labelArg.name}" cannot contain a comma`);
93
+ return labelArg.name;
94
+ }));
95
+ unique([...labels], `${where} label`);
96
+ const milestones = new Set((project.milestones ?? []).map((milestoneArg) => milestoneArg.title));
97
+ unique([...milestones], `${where} milestone`);
98
+ unique((project.tags ?? []).map((tagArg) => tagArg.name), `${where} tag`);
99
+ unique((project.issuesAndMergeRequests ?? []).map((itemArg) => itemArg.key), `${where} item key`);
100
+ for (const item of project.issuesAndMergeRequests ?? []) {
101
+ knownUser(item.author);
102
+ for (const label of item.labels ?? []) if (!labels.has(label)) fail(`${where} ${item.key} uses unknown label "${label}"`);
103
+ if (item.kind === 'issue') {
104
+ if (item.milestone !== undefined && !milestones.has(item.milestone)) fail(`${where} ${item.key} uses an unknown milestone`);
105
+ for (const assignee of item.assignees ?? []) knownUser(assignee);
106
+ for (const note of item.notes ?? []) knownUser(note.author);
107
+ } else if (!branches.has(item.sourceBranch) || !branches.has(item.targetBranch) || item.sourceBranch === item.targetBranch) {
108
+ fail(`${where} ${item.key} needs two distinct declared branches`);
109
+ }
110
+ }
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Seeds a running {@link GitlabFixture} through GitLab's documented REST API,
116
+ * then reads everything back into a ground-truth manifest.
117
+ */
118
+ export class GitlabSeedBuilder {
119
+ readonly #fixture: GitlabFixture;
120
+
121
+ constructor(fixtureArg: GitlabFixture) {
122
+ this.#fixture = fixtureArg;
123
+ }
124
+
125
+ public async apply(specArg: IGitlabSeedSpec): Promise<IGitlabSeedManifest> {
126
+ validateGitlabSeedSpec(specArg);
127
+ const runtime = this.#fixture.runtime;
128
+ const http = this.#fixture.http;
129
+ const users = new Map<string, number>();
130
+ for (const user of specArg.users) {
131
+ const created = await this.#api(http, 'POST', '/api/v4/users', [201], {
132
+ json: {
133
+ username: user.username,
134
+ name: user.name ?? user.username,
135
+ email: `${user.username.toLowerCase()}@example.com`,
136
+ password: plugins.crypto.randomBytes(24).toString('base64url'),
137
+ skip_confirmation: true,
138
+ },
139
+ });
140
+ users.set(user.username, responses.integer(responses.field(created, 'id', 'created user'), 'user id'));
141
+ }
142
+ const groups = new Map<string, number>();
143
+ const manifestGroups: IGitlabSeedManifest['groups'] = [];
144
+ for (const group of specArg.groups) {
145
+ const parentId = group.parent === undefined ? null : groups.get(group.parent)!;
146
+ const created = responses.record(await this.#api(http, 'POST', '/api/v4/groups', [201], {
147
+ json: { name: group.path, path: group.path, visibility: group.visibility, ...(parentId === null ? {} : { parent_id: parentId }) },
148
+ }), 'created group');
149
+ const id = responses.integer(created.id, 'group id');
150
+ const fullPath = responses.text(created.full_path, 'group full path');
151
+ groups.set(fullPath, id);
152
+ for (const member of group.members ?? []) {
153
+ await this.#api(http, 'POST', `/api/v4/groups/${id}/members`, [201], {
154
+ json: { user_id: users.get(member.username)!, access_level: accessLevels[member.accessLevel] },
155
+ });
156
+ }
157
+ manifestGroups.push({
158
+ fullPath, id,
159
+ parentId: created.parent_id === null ? null : responses.integer(created.parent_id, 'group parent id'),
160
+ visibility: this.#visibility(created.visibility),
161
+ });
162
+ }
163
+ const seeded: ISeededProjectRefs[] = [];
164
+ for (const project of specArg.projects) seeded.push(await this.#seedProject(http, project, groups, users));
165
+ for (const username of specArg.deleteUsers ?? []) await this.#deleteUser(http, users.get(username)!);
166
+ const deleted = new Set(specArg.deleteUsers ?? []);
167
+ const projects: IGitlabSeedManifestProject[] = [];
168
+ for (const refs of seeded) projects.push(await this.#readBack(http, refs));
169
+ return {
170
+ baseUrl: runtime.baseUrl,
171
+ version: runtime.version,
172
+ users: [...users].map(([username, id]) => ({ username, id, deleted: deleted.has(username) })),
173
+ groups: manifestGroups,
174
+ projects,
175
+ };
176
+ }
177
+
178
+ async #seedProject(
179
+ httpArg: ForgeFixtureHttpClient,
180
+ specArg: IGitlabSeedProject,
181
+ groupsArg: Map<string, number>,
182
+ usersArg: Map<string, number>,
183
+ ): Promise<ISeededProjectRefs> {
184
+ const body = {
185
+ name: specArg.path, path: specArg.path, visibility: specArg.visibility, description: specArg.description ?? '',
186
+ initialize_with_readme: true, default_branch: defaultBranch,
187
+ };
188
+ const groupId = groupsArg.get(specArg.namespace);
189
+ const created = groupId !== undefined
190
+ ? await this.#api(httpArg, 'POST', '/api/v4/projects', [201], { json: { ...body, namespace_id: groupId } })
191
+ : await this.#api(httpArg, 'POST', `/api/v4/projects/user/${usersArg.get(specArg.namespace)!}`, [201], { json: body });
192
+ const id = responses.integer(responses.field(created, 'id', 'created project'), 'project id');
193
+ const projectPath = `/api/v4/projects/${id}`;
194
+ const refs: ISeededProjectRefs = {
195
+ spec: specArg, id, labels: new Map(), milestones: new Map(), tags: [], items: [], releases: [], accessibleTo: new Set(),
196
+ };
197
+ for (const member of specArg.members ?? []) {
198
+ await this.#api(httpArg, 'POST', `${projectPath}/members`, [201], {
199
+ json: { user_id: usersArg.get(member.username)!, access_level: accessLevels[member.accessLevel] },
200
+ });
201
+ }
202
+ if ((specArg.files ?? []).length > 0) {
203
+ await this.#commitFiles(httpArg, projectPath, specArg.files!, { branch: defaultBranch }, 'Seed default branch files');
204
+ }
205
+ for (const branch of specArg.branches ?? []) {
206
+ await this.#commitFiles(httpArg, projectPath, branch.files, {
207
+ branch: branch.name, start_branch: branch.from ?? defaultBranch,
208
+ }, `Seed branch ${branch.name}`);
209
+ }
210
+ for (const label of specArg.labels ?? []) {
211
+ const createdLabel = await this.#api(httpArg, 'POST', `${projectPath}/labels`, [201], {
212
+ json: { name: label.name, color: label.color, description: label.description ?? '' },
213
+ });
214
+ refs.labels.set(label.name, responses.integer(responses.field(createdLabel, 'id', 'created label'), 'label id'));
215
+ }
216
+ // Milestones are closed only after the items: GitLab silently ignores a closed
217
+ // milestone assigned through the API (its milestone finder defaults to active).
218
+ for (const milestone of specArg.milestones ?? []) {
219
+ const createdMilestone = responses.record(await this.#api(httpArg, 'POST', `${projectPath}/milestones`, [201], {
220
+ json: { title: milestone.title, description: milestone.description ?? '' },
221
+ }), 'created milestone');
222
+ refs.milestones.set(milestone.title, {
223
+ id: responses.integer(createdMilestone.id, 'milestone id'),
224
+ iid: responses.integer(createdMilestone.iid, 'milestone iid'),
225
+ state: this.#milestoneState(createdMilestone.state),
226
+ });
227
+ }
228
+ for (const tag of specArg.tags ?? []) {
229
+ const createdTag = await this.#api(httpArg, 'POST', `${projectPath}/repository/tags`, [201], {
230
+ json: { tag_name: tag.name, ref: tag.ref, ...(tag.message === undefined ? {} : { message: tag.message }) },
231
+ });
232
+ refs.tags.push({
233
+ name: tag.name,
234
+ commitSha: responses.text(responses.field(responses.field(createdTag, 'commit', 'tag'), 'id', 'tag commit'), 'tag commit id'),
235
+ });
236
+ }
237
+ for (const item of specArg.issuesAndMergeRequests ?? []) {
238
+ refs.items.push(item.kind === 'issue'
239
+ ? await this.#seedIssue(httpArg, projectPath, refs, usersArg, item)
240
+ : await this.#seedMergeRequest(httpArg, projectPath, refs, item));
241
+ }
242
+ for (const milestone of (specArg.milestones ?? []).filter((milestoneArg) => milestoneArg.state === 'closed')) {
243
+ const ref = refs.milestones.get(milestone.title)!;
244
+ const closed = await this.#api(httpArg, 'PUT', `${projectPath}/milestones/${ref.id}`, [200], { json: { state_event: 'close' } });
245
+ ref.state = this.#milestoneState(responses.field(closed, 'state', 'closed milestone'));
246
+ }
247
+ for (const release of specArg.releases ?? []) {
248
+ await this.#api(httpArg, 'POST', `${projectPath}/releases`, [201], {
249
+ json: { tag_name: release.tagName, ref: release.ref, name: release.name, description: release.description ?? '' },
250
+ });
251
+ refs.releases.push({ tagName: release.tagName, name: release.name });
252
+ }
253
+ return refs;
254
+ }
255
+
256
+ async #seedIssue(
257
+ httpArg: ForgeFixtureHttpClient,
258
+ projectPathArg: string,
259
+ refsArg: ISeededProjectRefs,
260
+ usersArg: Map<string, number>,
261
+ issueArg: IGitlabSeedIssue,
262
+ ): Promise<ISeededItemRef> {
263
+ await this.#awaitProjectAccess(httpArg, refsArg, issueArg.author);
264
+ const created = await this.#api(httpArg, 'POST', `${projectPathArg}/issues`, [201], {
265
+ sudo: issueArg.author,
266
+ json: {
267
+ title: issueArg.title, description: issueArg.description ?? '',
268
+ issue_type: issueArg.issueType ?? 'issue', confidential: issueArg.confidential ?? false,
269
+ },
270
+ });
271
+ const iid = responses.integer(responses.field(created, 'iid', 'created issue'), 'issue iid');
272
+ const issuePath = `${projectPathArg}/issues/${iid}`;
273
+ for (const note of issueArg.notes ?? []) {
274
+ await this.#awaitProjectAccess(httpArg, refsArg, note.author);
275
+ await this.#api(httpArg, 'POST', `${issuePath}/notes`, [201], { sudo: note.author, json: { body: note.body } });
276
+ }
277
+ const edit: Record<string, unknown> = {};
278
+ if (issueArg.labels !== undefined) edit.labels = issueArg.labels.join(',');
279
+ if (issueArg.milestone !== undefined) edit.milestone_id = refsArg.milestones.get(issueArg.milestone)!.id;
280
+ if (issueArg.assignees !== undefined) edit.assignee_ids = issueArg.assignees.map((usernameArg) => usersArg.get(usernameArg)!);
281
+ if (issueArg.state === 'closed') edit.state_event = 'close';
282
+ if (Object.keys(edit).length > 0) {
283
+ const updated = await this.#api(httpArg, 'PUT', issuePath, [200], { json: edit });
284
+ this.#assertApplied(`issue ${issueArg.key}`, updated, issueArg);
285
+ }
286
+ return { key: issueArg.key, kind: 'issue', iid };
287
+ }
288
+
289
+ async #seedMergeRequest(
290
+ httpArg: ForgeFixtureHttpClient,
291
+ projectPathArg: string,
292
+ refsArg: ISeededProjectRefs,
293
+ mergeRequestArg: IGitlabSeedMergeRequest,
294
+ ): Promise<ISeededItemRef> {
295
+ await this.#awaitProjectAccess(httpArg, refsArg, mergeRequestArg.author);
296
+ const created = await this.#api(httpArg, 'POST', `${projectPathArg}/merge_requests`, [201], {
297
+ sudo: mergeRequestArg.author,
298
+ json: {
299
+ title: mergeRequestArg.title, description: mergeRequestArg.description ?? '',
300
+ source_branch: mergeRequestArg.sourceBranch, target_branch: mergeRequestArg.targetBranch,
301
+ },
302
+ });
303
+ const iid = responses.integer(responses.field(created, 'iid', 'created merge request'), 'merge request iid');
304
+ const edit: Record<string, unknown> = {};
305
+ if (mergeRequestArg.labels !== undefined) edit.labels = mergeRequestArg.labels.join(',');
306
+ if (mergeRequestArg.state === 'closed') edit.state_event = 'close';
307
+ if (Object.keys(edit).length > 0) {
308
+ const updated = await this.#api(httpArg, 'PUT', `${projectPathArg}/merge_requests/${iid}`, [200], { json: edit });
309
+ this.#assertApplied(`merge request ${mergeRequestArg.key}`, updated, mergeRequestArg);
310
+ }
311
+ return { key: mergeRequestArg.key, kind: 'mergeRequest', iid };
312
+ }
313
+
314
+ async #commitFiles(
315
+ httpArg: ForgeFixtureHttpClient,
316
+ projectPathArg: string,
317
+ filesArg: IGitlabSeedFile[],
318
+ branchArg: { branch: string; start_branch?: string },
319
+ messageArg: string,
320
+ ): Promise<void> {
321
+ await this.#api(httpArg, 'POST', `${projectPathArg}/repository/commits`, [201], {
322
+ json: {
323
+ ...branchArg,
324
+ commit_message: messageArg,
325
+ actions: filesArg.map((fileArg) => ({ action: 'create', file_path: fileArg.path, content: fileArg.content })),
326
+ },
327
+ });
328
+ }
329
+
330
+ /**
331
+ * GitLab grants the members of a group access to a project created in it
332
+ * from a background job (`AuthorizedProjectUpdate::ProjectRecalculateWorker`),
333
+ * so a group member may not see a new project yet. Waits until the user,
334
+ * acting through sudo, can read the project.
335
+ */
336
+ async #awaitProjectAccess(httpArg: ForgeFixtureHttpClient, refsArg: ISeededProjectRefs, usernameArg: string): Promise<void> {
337
+ if (refsArg.accessibleTo.has(usernameArg)) return;
338
+ const deadline = Date.now() + projectAccessTimeoutMs;
339
+ while (true) {
340
+ const response = await httpArg.request({
341
+ method: 'GET', url: `/api/v4/projects/${refsArg.id}`, headers: this.#headers(usernameArg), expectedStatus: [200, 404],
342
+ });
343
+ if (response.status === 200) {
344
+ refsArg.accessibleTo.add(usernameArg);
345
+ return;
346
+ }
347
+ if (Date.now() >= deadline) {
348
+ throw new Error(
349
+ `${usernameArg} cannot see ${refsArg.spec.namespace}/${refsArg.spec.path} after ${projectAccessTimeoutMs} ms; check the seed memberships.`,
350
+ );
351
+ }
352
+ await sleep(500);
353
+ }
354
+ }
355
+
356
+ /** User deletion runs in the background; wait until the user is gone and its content moved to the ghost user. */
357
+ async #deleteUser(httpArg: ForgeFixtureHttpClient, userIdArg: number): Promise<void> {
358
+ await this.#api(httpArg, 'DELETE', `/api/v4/users/${userIdArg}`, [204]);
359
+ const deadline = Date.now() + userDeletionTimeoutMs;
360
+ while (Date.now() < deadline) {
361
+ const response = await httpArg.request({
362
+ method: 'GET', url: `/api/v4/users/${userIdArg}`, headers: this.#headers(), expectedStatus: [200, 404],
363
+ });
364
+ if (response.status === 404) return;
365
+ await sleep(1_000);
366
+ }
367
+ throw new Error(`GitLab did not finish deleting user ${userIdArg} within ${userDeletionTimeoutMs} ms.`);
368
+ }
369
+
370
+ async #readBack(httpArg: ForgeFixtureHttpClient, refsArg: ISeededProjectRefs): Promise<IGitlabSeedManifestProject> {
371
+ const specArg = refsArg.spec;
372
+ const projectPath = `/api/v4/projects/${refsArg.id}`;
373
+ const project = responses.record(await this.#api(httpArg, 'GET', projectPath, [200]), 'project');
374
+ const namespace = responses.record(project.namespace, 'project namespace');
375
+ const branches: IGitlabSeedManifestProject['branches'] = [];
376
+ for (const name of [defaultBranch, ...(specArg.branches ?? []).map((branchArg) => branchArg.name)]) {
377
+ const branch = await this.#api(httpArg, 'GET', `${projectPath}/repository/branches/${encodeURIComponent(name)}`, [200]);
378
+ branches.push({ name, commitSha: responses.text(responses.field(responses.field(branch, 'commit', 'branch'), 'id', 'branch commit'), 'branch commit id') });
379
+ }
380
+ const issues: IGitlabSeedManifestProject['issues'] = [];
381
+ const mergeRequests: IGitlabSeedManifestProject['mergeRequests'] = [];
382
+ for (const item of refsArg.items) {
383
+ if (item.kind === 'issue') {
384
+ const issue = responses.record(await this.#api(httpArg, 'GET', `${projectPath}/issues/${item.iid}`, [200]), 'issue');
385
+ const notes = responses.array(await this.#api(httpArg, 'GET', `${projectPath}/issues/${item.iid}/notes`, [200], {
386
+ query: { sort: 'asc', order_by: 'created_at', per_page: '100' },
387
+ }), 'notes').map((noteArg) => responses.record(noteArg, 'note')).filter((noteArg) => noteArg.system === false);
388
+ issues.push({
389
+ key: item.key,
390
+ id: responses.integer(issue.id, 'issue id'),
391
+ iid: responses.integer(issue.iid, 'issue iid'),
392
+ state: this.#itemState(issue.state),
393
+ issueType: responses.text(issue.issue_type, 'issue type'),
394
+ confidential: responses.boolean(issue.confidential, 'issue confidential'),
395
+ author: this.#author(issue.author),
396
+ labels: responses.array(issue.labels, 'issue labels').map((labelArg) => responses.text(labelArg, 'label name')),
397
+ milestone: issue.milestone === null || issue.milestone === undefined
398
+ ? null
399
+ : responses.text(responses.field(issue.milestone, 'title', 'issue milestone'), 'milestone title'),
400
+ assignees: responses.array(issue.assignees ?? [], 'issue assignees').map((assigneeArg) => this.#author(assigneeArg).username),
401
+ notes: notes.map((noteArg) => ({ id: responses.integer(noteArg.id, 'note id'), author: this.#author(noteArg.author) })),
402
+ });
403
+ } else {
404
+ const mergeRequest = responses.record(await this.#api(httpArg, 'GET', `${projectPath}/merge_requests/${item.iid}`, [200]), 'merge request');
405
+ mergeRequests.push({
406
+ key: item.key,
407
+ id: responses.integer(mergeRequest.id, 'merge request id'),
408
+ iid: responses.integer(mergeRequest.iid, 'merge request iid'),
409
+ state: this.#itemState(mergeRequest.state),
410
+ author: this.#author(mergeRequest.author),
411
+ sourceBranch: responses.text(mergeRequest.source_branch, 'merge request source branch'),
412
+ targetBranch: responses.text(mergeRequest.target_branch, 'merge request target branch'),
413
+ sha: responses.text(mergeRequest.sha, 'merge request sha'),
414
+ });
415
+ }
416
+ }
417
+ const namespaceKind = namespace.kind;
418
+ if (namespaceKind !== 'group' && namespaceKind !== 'user') throw new Error('Expected a group or user namespace.');
419
+ return {
420
+ pathWithNamespace: responses.text(project.path_with_namespace, 'project path'),
421
+ id: refsArg.id,
422
+ namespaceId: responses.integer(namespace.id, 'namespace id'),
423
+ namespaceKind,
424
+ visibility: this.#visibility(project.visibility),
425
+ defaultBranch: responses.text(project.default_branch, 'project default branch'),
426
+ webUrl: responses.text(project.web_url, 'project web url'),
427
+ httpUrlToRepo: responses.text(project.http_url_to_repo, 'project clone url'),
428
+ branches,
429
+ tags: refsArg.tags,
430
+ labels: [...refsArg.labels].map(([name, id]) => ({ name, id })),
431
+ milestones: [...refsArg.milestones].map(([title, milestone]) => ({ title, ...milestone })),
432
+ issues,
433
+ mergeRequests,
434
+ releases: refsArg.releases,
435
+ };
436
+ }
437
+
438
+ /** GitLab answers 200 even when it drops an attribute it refuses; seeding must not continue on a false premise. */
439
+ #assertApplied(whatArg: string, updatedArg: unknown, specArg: IGitlabSeedIssue | IGitlabSeedMergeRequest): void {
440
+ const updated = responses.record(updatedArg, whatArg);
441
+ const sorted = (valuesArg: string[]) => [...valuesArg].sort().join(',');
442
+ const dropped: string[] = [];
443
+ if (this.#itemState(updated.state) !== specArg.state) dropped.push('state');
444
+ if (specArg.labels !== undefined
445
+ && sorted(responses.array(updated.labels, 'labels').map((labelArg) => responses.text(labelArg, 'label'))) !== sorted(specArg.labels)) {
446
+ dropped.push('labels');
447
+ }
448
+ if (specArg.kind === 'issue') {
449
+ const milestone = updated.milestone === null || updated.milestone === undefined
450
+ ? undefined
451
+ : responses.text(responses.field(updated.milestone, 'title', 'milestone'), 'milestone title');
452
+ if (milestone !== specArg.milestone) dropped.push('milestone');
453
+ if (specArg.assignees !== undefined
454
+ && sorted(responses.array(updated.assignees, 'assignees').map((userArg) => this.#author(userArg).username)) !== sorted(specArg.assignees)) {
455
+ dropped.push('assignees');
456
+ }
457
+ }
458
+ if (dropped.length > 0) throw new Error(`GitLab did not apply the ${dropped.join(', ')} of ${whatArg}.`);
459
+ }
460
+
461
+ #author(userArg: unknown): IGitlabSeedManifestAuthor {
462
+ const user = responses.record(userArg, 'user');
463
+ return { id: responses.integer(user.id, 'user id'), username: responses.text(user.username, 'user username') };
464
+ }
465
+
466
+ #itemState(valueArg: unknown): 'opened' | 'closed' {
467
+ if (valueArg !== 'opened' && valueArg !== 'closed') throw new Error('Expected state to be opened or closed.');
468
+ return valueArg;
469
+ }
470
+
471
+ #milestoneState(valueArg: unknown): 'active' | 'closed' {
472
+ if (valueArg !== 'active' && valueArg !== 'closed') throw new Error('Expected milestone state to be active or closed.');
473
+ return valueArg;
474
+ }
475
+
476
+ #visibility(valueArg: unknown): TGitlabVisibility {
477
+ if (valueArg !== 'public' && valueArg !== 'internal' && valueArg !== 'private') {
478
+ throw new Error('Expected visibility to be public, internal or private.');
479
+ }
480
+ return valueArg;
481
+ }
482
+
483
+ #headers(sudoArg?: string): Record<string, string> {
484
+ const headers: Record<string, string> = { 'private-token': this.#fixture.runtime.admin.token };
485
+ if (sudoArg !== undefined) headers.sudo = sudoArg;
486
+ return headers;
487
+ }
488
+
489
+ async #api(
490
+ httpArg: ForgeFixtureHttpClient,
491
+ methodArg: 'GET' | 'POST' | 'PUT' | 'DELETE',
492
+ pathArg: string,
493
+ expectedStatusArg: number[],
494
+ optionsArg: { json?: unknown; sudo?: string; query?: Record<string, string> } = {},
495
+ ): Promise<unknown> {
496
+ return httpArg.requestJson({
497
+ method: methodArg, url: pathArg, headers: this.#headers(optionsArg.sudo), expectedStatus: expectedStatusArg,
498
+ ...(optionsArg.json === undefined ? {} : { json: optionsArg.json }),
499
+ ...(optionsArg.query === undefined ? {} : { query: optionsArg.query }),
500
+ });
501
+ }
502
+ }
@@ -0,0 +1,156 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ export type TForgeFixtureHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
4
+
5
+ export interface IForgeFixtureHttpRequest {
6
+ method: TForgeFixtureHttpMethod;
7
+ /** Path below the base URL (starting with `/`), or an absolute URL on the exact same origin. */
8
+ url: string;
9
+ query?: Record<string, string>;
10
+ /** Request headers, including credentials. Never included in errors. */
11
+ headers?: Record<string, string>;
12
+ /** JSON request body. Mutually exclusive with `body`. */
13
+ json?: unknown;
14
+ /** Raw request body. Mutually exclusive with `json`. */
15
+ body?: Uint8Array;
16
+ expectedStatus: readonly number[];
17
+ timeoutMs?: number;
18
+ maxResponseBytes?: number;
19
+ }
20
+
21
+ export interface IForgeFixtureHttpResponse {
22
+ status: number;
23
+ headers: plugins.http.IncomingHttpHeaders;
24
+ body: Buffer;
25
+ }
26
+
27
+ export interface IForgeFixtureHttpClientOptions {
28
+ /** HTTPS origin, optionally with a path prefix, without credentials. */
29
+ baseUrl: string;
30
+ /** The only certificate authority this client trusts. */
31
+ caCertificatePem: string;
32
+ requestTimeoutMs?: number;
33
+ maxResponseBytes?: number;
34
+ }
35
+
36
+ export class ForgeFixtureHttpError extends Error {
37
+ public readonly status: number;
38
+ public readonly method: string;
39
+ public readonly path: string;
40
+
41
+ constructor(methodArg: string, pathArg: string, statusArg: number, bodyExcerptArg: string) {
42
+ super(`${methodArg} ${pathArg} answered ${statusArg}: ${bodyExcerptArg}`);
43
+ this.name = 'ForgeFixtureHttpError';
44
+ this.status = statusArg;
45
+ this.method = methodArg;
46
+ this.path = pathArg;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * HTTPS client scoped to one fixture: it trusts only the fixture CA, talks only
52
+ * to the fixture origin, never follows redirects, and bounds time and body size.
53
+ * It changes no process-global TLS state.
54
+ */
55
+ export class ForgeFixtureHttpClient {
56
+ readonly #baseUrl: URL;
57
+ readonly #agent: plugins.https.Agent;
58
+ readonly #requestTimeoutMs: number;
59
+ readonly #maxResponseBytes: number;
60
+
61
+ constructor(optionsArg: IForgeFixtureHttpClientOptions) {
62
+ const baseUrl = new URL(optionsArg.baseUrl);
63
+ if (baseUrl.protocol !== 'https:' || baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
64
+ throw new TypeError('The fixture base URL must be a credential-free HTTPS URL.');
65
+ }
66
+ this.#baseUrl = new URL(baseUrl.href.endsWith('/') ? baseUrl.href : `${baseUrl.href}/`);
67
+ this.#agent = new plugins.https.Agent({ ca: [optionsArg.caCertificatePem], keepAlive: true, maxSockets: 8 });
68
+ this.#requestTimeoutMs = optionsArg.requestTimeoutMs ?? 60_000;
69
+ this.#maxResponseBytes = optionsArg.maxResponseBytes ?? 16 * 1024 * 1024;
70
+ }
71
+
72
+ public get baseUrl(): string {
73
+ return this.#baseUrl.href.replace(/\/$/, '');
74
+ }
75
+
76
+ /** Resolves a request target and refuses anything outside the fixture origin. */
77
+ public resolve(urlArg: string, queryArg?: Record<string, string>): URL {
78
+ const url = /^https?:\/\//.test(urlArg)
79
+ ? new URL(urlArg)
80
+ : new URL(urlArg.replace(/^\//, ''), this.#baseUrl);
81
+ if (url.origin !== this.#baseUrl.origin || url.username || url.password) {
82
+ throw new Error(`Refusing a request outside the fixture origin: ${url.origin}`);
83
+ }
84
+ for (const [key, value] of Object.entries(queryArg ?? {})) url.searchParams.set(key, value);
85
+ return url;
86
+ }
87
+
88
+ public async request(requestArg: IForgeFixtureHttpRequest): Promise<IForgeFixtureHttpResponse> {
89
+ if (requestArg.json !== undefined && requestArg.body !== undefined) {
90
+ throw new TypeError('A fixture request cannot carry both json and body.');
91
+ }
92
+ const url = this.resolve(requestArg.url, requestArg.query);
93
+ const payload = requestArg.json !== undefined
94
+ ? Buffer.from(JSON.stringify(requestArg.json))
95
+ : requestArg.body === undefined ? undefined : Buffer.from(requestArg.body);
96
+ const headers: Record<string, string> = { accept: 'application/json' };
97
+ for (const [name, value] of Object.entries(requestArg.headers ?? {})) headers[name.toLowerCase()] = value;
98
+ if (requestArg.json !== undefined) headers['content-type'] = 'application/json';
99
+ // A caller-requested transfer coding (for example Git LFS upload actions) replaces Content-Length.
100
+ if (payload !== undefined && headers['transfer-encoding'] === undefined) {
101
+ headers['content-length'] = String(payload.byteLength);
102
+ }
103
+ const timeoutMs = requestArg.timeoutMs ?? this.#requestTimeoutMs;
104
+ const maxResponseBytes = requestArg.maxResponseBytes ?? this.#maxResponseBytes;
105
+
106
+ const response = await new Promise<IForgeFixtureHttpResponse>((resolveArg, rejectArg) => {
107
+ const request = plugins.https.request(url, {
108
+ method: requestArg.method,
109
+ headers,
110
+ agent: this.#agent,
111
+ signal: AbortSignal.timeout(timeoutMs),
112
+ }, (responseArg) => {
113
+ const chunks: Buffer[] = [];
114
+ let size = 0;
115
+ responseArg.on('data', (chunkArg: Buffer) => {
116
+ size += chunkArg.byteLength;
117
+ if (size > maxResponseBytes) {
118
+ responseArg.destroy(new Error(`${requestArg.method} ${url.pathname} exceeded ${maxResponseBytes} response bytes.`));
119
+ return;
120
+ }
121
+ chunks.push(chunkArg);
122
+ });
123
+ responseArg.once('error', rejectArg);
124
+ responseArg.once('end', () => resolveArg({
125
+ status: responseArg.statusCode ?? 0,
126
+ headers: responseArg.headers,
127
+ body: Buffer.concat(chunks),
128
+ }));
129
+ });
130
+ request.once('error', rejectArg);
131
+ request.end(payload);
132
+ });
133
+ if (!requestArg.expectedStatus.includes(response.status)) {
134
+ throw new ForgeFixtureHttpError(
135
+ requestArg.method, url.pathname, response.status, response.body.subarray(0, 512).toString('utf8'),
136
+ );
137
+ }
138
+ return response;
139
+ }
140
+
141
+ /** Performs a request and parses its JSON body. An empty body yields `null`. */
142
+ public async requestJson(requestArg: IForgeFixtureHttpRequest): Promise<unknown> {
143
+ const response = await this.request(requestArg);
144
+ if (response.body.byteLength === 0) return null;
145
+ const contentType = response.headers['content-type']?.split(';')[0]?.trim().toLowerCase();
146
+ if (contentType !== 'application/json' && contentType !== 'application/vnd.git-lfs+json') {
147
+ throw new Error(`${requestArg.method} ${requestArg.url} answered ${contentType ?? 'no content type'} instead of JSON.`);
148
+ }
149
+ return JSON.parse(response.body.toString('utf8')) as unknown;
150
+ }
151
+
152
+ /** Destroys pooled connections. The client is unusable afterwards. */
153
+ public close(): void {
154
+ this.#agent.destroy();
155
+ }
156
+ }