@postman-cse/onboarding-bootstrap 0.10.0 → 0.12.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.
- package/LICENSE +21 -0
- package/README.md +110 -10
- package/action.yml +22 -2
- package/dist/cli.cjs +34466 -2224
- package/dist/index.cjs +34358 -2195
- package/package.json +22 -3
- package/.github/actionlint.yaml +0 -2
- package/.github/workflows/ci.yml +0 -36
- package/.github/workflows/contract-smoke.yml +0 -155
- package/.github/workflows/release.yml +0 -62
- package/.omc/sessions/b36ac16d-ce26-4e94-ae7e-dd817f2430a3.json +0 -8
- package/CLAUDE.md +0 -57
- package/src/cli.ts +0 -280
- package/src/contracts.ts +0 -166
- package/src/index.ts +0 -1100
- package/src/lib/github/github-api-client.ts +0 -262
- package/src/lib/http-error.ts +0 -80
- package/src/lib/postman/internal-integration-adapter.ts +0 -272
- package/src/lib/postman/postman-assets-client.ts +0 -786
- package/src/lib/postman/workspace-selection.ts +0 -121
- package/src/lib/repo/context.ts +0 -119
- package/src/lib/retry.ts +0 -79
- package/src/lib/secrets.ts +0 -104
- package/tests/bootstrap-action.test.ts +0 -1217
- package/tests/cli.test.ts +0 -150
- package/tests/contract.test.ts +0 -123
- package/tests/github-api-client.test.ts +0 -91
- package/tests/internal-integration-adapter.test.ts +0 -244
- package/tests/postman-assets-client.test.ts +0 -268
- package/tests/retry.test.ts +0 -54
- package/tests/secrets.test.ts +0 -66
- package/tests/workspace-selection.test.ts +0 -301
- package/tmp/cli-run-result.json +0 -11
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -8
|
@@ -1,786 +0,0 @@
|
|
|
1
|
-
import { HttpError } from '../http-error.js';
|
|
2
|
-
import { retry } from '../retry.js';
|
|
3
|
-
import { createSecretMasker, type SecretMasker } from '../secrets.js';
|
|
4
|
-
|
|
5
|
-
type EnvironmentValue = {
|
|
6
|
-
key: string;
|
|
7
|
-
type: string;
|
|
8
|
-
value: string;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
type FetchResult = Record<string, any> | null;
|
|
12
|
-
|
|
13
|
-
export interface PostmanAssetsClientOptions {
|
|
14
|
-
apiKey: string;
|
|
15
|
-
baseUrl?: string;
|
|
16
|
-
fetchImpl?: typeof fetch;
|
|
17
|
-
secretMasker?: SecretMasker;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function normalizeGitRepoUrl(url: string | null | undefined): string {
|
|
21
|
-
const raw = String(url || '').trim();
|
|
22
|
-
if (!raw) return '';
|
|
23
|
-
|
|
24
|
-
// git@<host>:<owner>/<repo>.git -> https://<host>/<owner>/<repo>
|
|
25
|
-
const sshMatch = raw.match(/^git@([^:]+):(.+)$/i);
|
|
26
|
-
if (sshMatch?.[1] && sshMatch?.[2]) {
|
|
27
|
-
return normalizeGitRepoUrl(`https://${sshMatch[1]}/${sshMatch[2]}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
const parsed = new URL(raw);
|
|
32
|
-
const host = parsed.hostname.toLowerCase();
|
|
33
|
-
const parts = parsed.pathname
|
|
34
|
-
.replace(/^\/+|\/+$/g, '')
|
|
35
|
-
.replace(/\.git$/i, '')
|
|
36
|
-
.split('/')
|
|
37
|
-
.filter(Boolean);
|
|
38
|
-
if (parts.length < 2) return raw.replace(/\.git$/i, '').replace(/\/+$/g, '').toLowerCase();
|
|
39
|
-
return `https://${host}/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}`;
|
|
40
|
-
} catch {
|
|
41
|
-
return raw.replace(/\.git$/i, '').replace(/\/+$/g, '').toLowerCase();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function extractGitRepoUrl(value: unknown): string | null {
|
|
46
|
-
if (!value) return null;
|
|
47
|
-
if (typeof value === 'string') {
|
|
48
|
-
const normalized = normalizeGitRepoUrl(value);
|
|
49
|
-
// Accept any URL that looks like a hosted git repo (github.com, gitlab.com, or self-hosted)
|
|
50
|
-
if (/^https:\/\/[^/]+\/[^/]+\/[^/]+$/.test(normalized)) return normalized;
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
if (Array.isArray(value)) {
|
|
54
|
-
for (const item of value) {
|
|
55
|
-
const repoUrl = extractGitRepoUrl(item);
|
|
56
|
-
if (repoUrl) return repoUrl;
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
if (typeof value === 'object') {
|
|
61
|
-
const record = value as Record<string, unknown>;
|
|
62
|
-
const preferredKeys = ['repo', 'repository', 'repoUrl', 'repo_url', 'remoteUrl', 'remote_url', 'origin'];
|
|
63
|
-
for (const key of preferredKeys) {
|
|
64
|
-
const repoUrl = extractGitRepoUrl(record[key]);
|
|
65
|
-
if (repoUrl) return repoUrl;
|
|
66
|
-
}
|
|
67
|
-
for (const nested of Object.values(record)) {
|
|
68
|
-
const repoUrl = extractGitRepoUrl(nested);
|
|
69
|
-
if (repoUrl) return repoUrl;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export class PostmanAssetsClient {
|
|
76
|
-
private readonly apiKey: string;
|
|
77
|
-
private readonly baseUrl: string;
|
|
78
|
-
private readonly fetchImpl: typeof fetch;
|
|
79
|
-
private readonly secretMasker: SecretMasker;
|
|
80
|
-
|
|
81
|
-
constructor(options: PostmanAssetsClientOptions) {
|
|
82
|
-
this.apiKey = String(options.apiKey || '').trim();
|
|
83
|
-
this.baseUrl = String(options.baseUrl || 'https://api.getpostman.com').replace(
|
|
84
|
-
/\/+$/,
|
|
85
|
-
''
|
|
86
|
-
);
|
|
87
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
88
|
-
this.secretMasker =
|
|
89
|
-
options.secretMasker ?? createSecretMasker([this.apiKey]);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
getBaseUrl(): string {
|
|
93
|
-
return this.baseUrl;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async getMe(): Promise<Record<string, unknown> | null> {
|
|
97
|
-
return this.request('/me', { method: 'GET' }) as Promise<Record<string, unknown> | null>;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async getAutoDerivedTeamId(): Promise<string | undefined> {
|
|
101
|
-
try {
|
|
102
|
-
const data = await this.getMe();
|
|
103
|
-
const user = data?.user;
|
|
104
|
-
if (user && typeof user === 'object' && 'teamId' in user && user.teamId) {
|
|
105
|
-
return String(user.teamId);
|
|
106
|
-
}
|
|
107
|
-
} catch (e) {
|
|
108
|
-
// ignore
|
|
109
|
-
}
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async getTeams(): Promise<Array<{ id: number; name: string; handle: string; organizationId?: number }>> {
|
|
114
|
-
const data = await this.request('/teams');
|
|
115
|
-
const teams = data?.data ?? [];
|
|
116
|
-
return Array.isArray(teams)
|
|
117
|
-
? teams
|
|
118
|
-
.filter((t: any) => t?.id && t?.name)
|
|
119
|
-
.map((t: any) => ({
|
|
120
|
-
id: Number(t.id),
|
|
121
|
-
name: String(t.name),
|
|
122
|
-
handle: String(t.handle || ''),
|
|
123
|
-
...(t.organizationId != null ? { organizationId: Number(t.organizationId) } : {})
|
|
124
|
-
}))
|
|
125
|
-
: [];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private async request(
|
|
129
|
-
path: string,
|
|
130
|
-
init: RequestInit = {}
|
|
131
|
-
): Promise<FetchResult> {
|
|
132
|
-
const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`;
|
|
133
|
-
const response = await this.fetchImpl(url, {
|
|
134
|
-
...init,
|
|
135
|
-
headers: {
|
|
136
|
-
'Content-Type': 'application/json',
|
|
137
|
-
'X-Api-Key': this.apiKey,
|
|
138
|
-
...(init.headers || {})
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
if (!response.ok) {
|
|
143
|
-
throw await HttpError.fromResponse(response, {
|
|
144
|
-
method: init.method || 'GET',
|
|
145
|
-
requestHeaders: {
|
|
146
|
-
'Content-Type': 'application/json',
|
|
147
|
-
'X-Api-Key': this.apiKey,
|
|
148
|
-
...(init.headers || {})
|
|
149
|
-
},
|
|
150
|
-
secretValues: [this.apiKey],
|
|
151
|
-
url
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
return (await response.json()) as Record<string, any>;
|
|
157
|
-
} catch {
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async createWorkspace(name: string, about: string, targetTeamId?: number): Promise<{ id: string }> {
|
|
163
|
-
return retry(async () => {
|
|
164
|
-
const payload = {
|
|
165
|
-
workspace: {
|
|
166
|
-
about,
|
|
167
|
-
name,
|
|
168
|
-
type: 'team',
|
|
169
|
-
...(targetTeamId != null && !Number.isNaN(targetTeamId) ? { teamId: targetTeamId } : {})
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
let created: FetchResult;
|
|
173
|
-
try {
|
|
174
|
-
created = await this.request('/workspaces', {
|
|
175
|
-
method: 'POST',
|
|
176
|
-
body: JSON.stringify(payload)
|
|
177
|
-
});
|
|
178
|
-
} catch (err) {
|
|
179
|
-
if (err instanceof Error && err.message.includes('Only personal workspaces')) {
|
|
180
|
-
throw new Error(
|
|
181
|
-
'Workspace creation failed: This may be an Org-mode account that requires a workspace-team-id input. ' +
|
|
182
|
-
'The Postman API does not allow creating team workspaces at the organization level. ' +
|
|
183
|
-
'Use the workspace-team-id input to specify which sub-team should own this workspace.'
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
throw err;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const workspaceId = created?.workspace?.id;
|
|
190
|
-
if (!workspaceId) {
|
|
191
|
-
throw new Error('Workspace create did not return an id');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const workspace = await this.request(`/workspaces/${workspaceId}`);
|
|
195
|
-
if (workspace?.workspace?.visibility !== 'team') {
|
|
196
|
-
await this.request(`/workspaces/${workspaceId}`, {
|
|
197
|
-
method: 'PUT',
|
|
198
|
-
body: JSON.stringify(payload)
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
id: workspaceId
|
|
204
|
-
};
|
|
205
|
-
}, {
|
|
206
|
-
maxAttempts: 3,
|
|
207
|
-
delayMs: 2000,
|
|
208
|
-
shouldRetry: (err) =>
|
|
209
|
-
!(err instanceof Error && err.message.includes('workspace-team-id'))
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async listWorkspaces(): Promise<Array<{ id: string; name: string; type: string }>> {
|
|
214
|
-
const data = await this.request('/workspaces');
|
|
215
|
-
const workspaces = data?.workspaces ?? [];
|
|
216
|
-
return Array.isArray(workspaces)
|
|
217
|
-
? workspaces
|
|
218
|
-
.filter((w: any) => w?.id && w?.name)
|
|
219
|
-
.map((w: any) => ({
|
|
220
|
-
id: String(w.id),
|
|
221
|
-
name: String(w.name),
|
|
222
|
-
type: String(w.type ?? 'team')
|
|
223
|
-
}))
|
|
224
|
-
: [];
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async findWorkspacesByName(name: string): Promise<Array<{ id: string; name: string }>> {
|
|
228
|
-
const workspaces = await this.listWorkspaces();
|
|
229
|
-
return workspaces
|
|
230
|
-
.filter((w) => w.name === name)
|
|
231
|
-
.sort((a, b) => a.id.localeCompare(b.id))
|
|
232
|
-
.map((w) => ({ id: w.id, name: w.name }));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async getWorkspaceGitRepoUrl(
|
|
236
|
-
workspaceId: string,
|
|
237
|
-
teamId: string,
|
|
238
|
-
accessToken: string
|
|
239
|
-
): Promise<string | null> {
|
|
240
|
-
const url = 'https://bifrost-premium-https-v4.gw.postman.com/ws/proxy';
|
|
241
|
-
const headers: Record<string, string> = {
|
|
242
|
-
'x-access-token': accessToken,
|
|
243
|
-
'Content-Type': 'application/json'
|
|
244
|
-
};
|
|
245
|
-
if (teamId) {
|
|
246
|
-
headers['x-entity-team-id'] = teamId;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const response = await this.fetchImpl(url, {
|
|
250
|
-
method: 'POST',
|
|
251
|
-
headers,
|
|
252
|
-
body: JSON.stringify({
|
|
253
|
-
service: 'workspaces',
|
|
254
|
-
method: 'GET',
|
|
255
|
-
path: `/workspaces/${workspaceId}/filesystem`
|
|
256
|
-
})
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
if (response.status === 404) return null;
|
|
260
|
-
if (!response.ok) {
|
|
261
|
-
const body = await response.text();
|
|
262
|
-
throw new Error(`Bifrost workspace lookup failed: ${response.status} - ${body}`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const body = await response.text();
|
|
266
|
-
if (!body.trim()) return null;
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
return extractGitRepoUrl(JSON.parse(body));
|
|
270
|
-
} catch {
|
|
271
|
-
return extractGitRepoUrl(body);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async inviteRequesterToWorkspace(
|
|
276
|
-
workspaceId: string,
|
|
277
|
-
email: string
|
|
278
|
-
): Promise<void> {
|
|
279
|
-
const users = await this.request('/users');
|
|
280
|
-
const user = users?.data?.find((entry: any) => entry.email === email);
|
|
281
|
-
if (!user?.id) {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
await this.request(`/workspaces/${workspaceId}/roles`, {
|
|
286
|
-
method: 'PATCH',
|
|
287
|
-
body: JSON.stringify({
|
|
288
|
-
roles: [
|
|
289
|
-
{
|
|
290
|
-
op: 'add',
|
|
291
|
-
path: '/user',
|
|
292
|
-
value: [{ id: user.id, role: 2 }]
|
|
293
|
-
}
|
|
294
|
-
]
|
|
295
|
-
})
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async addAdminsToWorkspace(
|
|
300
|
-
workspaceId: string,
|
|
301
|
-
adminIds: string
|
|
302
|
-
): Promise<void> {
|
|
303
|
-
const ids = String(adminIds || '')
|
|
304
|
-
.split(',')
|
|
305
|
-
.map((entry) => entry.trim())
|
|
306
|
-
.filter(Boolean);
|
|
307
|
-
if (ids.length === 0) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
await this.request(`/workspaces/${workspaceId}/roles`, {
|
|
312
|
-
method: 'PATCH',
|
|
313
|
-
body: JSON.stringify({
|
|
314
|
-
roles: [
|
|
315
|
-
{
|
|
316
|
-
op: 'add',
|
|
317
|
-
path: '/user',
|
|
318
|
-
value: ids.map((id) => ({
|
|
319
|
-
id: Number.parseInt(id, 10),
|
|
320
|
-
role: 3
|
|
321
|
-
}))
|
|
322
|
-
}
|
|
323
|
-
]
|
|
324
|
-
})
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async uploadSpec(
|
|
329
|
-
workspaceId: string,
|
|
330
|
-
projectName: string,
|
|
331
|
-
specContent: string
|
|
332
|
-
): Promise<string> {
|
|
333
|
-
const response = await this.request(`/specs?workspaceId=${workspaceId}`, {
|
|
334
|
-
method: 'POST',
|
|
335
|
-
body: JSON.stringify({
|
|
336
|
-
name: projectName,
|
|
337
|
-
type: 'OPENAPI:3.0',
|
|
338
|
-
files: [{ path: 'index.yaml', content: specContent }]
|
|
339
|
-
})
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
const specId = String(response?.id || '').trim();
|
|
343
|
-
if (!specId) {
|
|
344
|
-
throw new Error('Spec upload did not return an ID');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
await retry(async () => {
|
|
348
|
-
const verified = await this.request(`/specs/${specId}?workspaceId=${workspaceId}`);
|
|
349
|
-
if (verified?.id !== specId) {
|
|
350
|
-
throw new Error(`Spec preflight response did not contain expected id ${specId}`);
|
|
351
|
-
}
|
|
352
|
-
}, { maxAttempts: 3, delayMs: 2000 });
|
|
353
|
-
|
|
354
|
-
return specId;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
async updateSpec(
|
|
359
|
-
specId: string,
|
|
360
|
-
specContent: string,
|
|
361
|
-
_workspaceId?: string
|
|
362
|
-
): Promise<void> {
|
|
363
|
-
// Postman Spec Hub uses PATCH /specs/{specId}/files/{filePath} for updates.
|
|
364
|
-
// PUT /specs/{specId} is not a valid endpoint and returns 404.
|
|
365
|
-
await this.request(`/specs/${specId}/files/index.yaml`, {
|
|
366
|
-
method: 'PATCH',
|
|
367
|
-
body: JSON.stringify({ content: specContent })
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async getSpecContent(specId: string): Promise<string | undefined> {
|
|
372
|
-
try {
|
|
373
|
-
const result = await this.request(`/specs/${specId}/files/index.yaml`);
|
|
374
|
-
return typeof result?.content === 'string' ? result.content : undefined;
|
|
375
|
-
} catch {
|
|
376
|
-
return undefined;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async generateCollection(
|
|
381
|
-
specId: string,
|
|
382
|
-
projectName: string,
|
|
383
|
-
prefix: string
|
|
384
|
-
): Promise<string> {
|
|
385
|
-
const payload = {
|
|
386
|
-
name: `${prefix} ${projectName}`,
|
|
387
|
-
options: {
|
|
388
|
-
requestNameSource: 'Fallback'
|
|
389
|
-
}
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
const extractUid = (data: any): string | undefined =>
|
|
393
|
-
data?.details?.resources?.[0]?.id ||
|
|
394
|
-
data?.collection?.id ||
|
|
395
|
-
data?.collection?.uid ||
|
|
396
|
-
data?.resource?.uid ||
|
|
397
|
-
data?.resource?.id ||
|
|
398
|
-
undefined;
|
|
399
|
-
|
|
400
|
-
return retry(
|
|
401
|
-
async () => {
|
|
402
|
-
const maxLockedRetries = 5;
|
|
403
|
-
let response: FetchResult = null;
|
|
404
|
-
|
|
405
|
-
for (let lockedAttempt = 0; ; lockedAttempt += 1) {
|
|
406
|
-
try {
|
|
407
|
-
response = await this.request(`/specs/${specId}/generations/collection`, {
|
|
408
|
-
method: 'POST',
|
|
409
|
-
body: JSON.stringify(payload)
|
|
410
|
-
});
|
|
411
|
-
break;
|
|
412
|
-
} catch (error) {
|
|
413
|
-
const message = this.secretMasker(
|
|
414
|
-
error instanceof Error ? error.message : String(error)
|
|
415
|
-
);
|
|
416
|
-
const isLocked = message.includes('423');
|
|
417
|
-
if (!isLocked || lockedAttempt >= maxLockedRetries) {
|
|
418
|
-
throw error;
|
|
419
|
-
}
|
|
420
|
-
await new Promise((resolve) => {
|
|
421
|
-
setTimeout(resolve, 5000 * Math.pow(2, lockedAttempt));
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const directUid = extractUid(response);
|
|
427
|
-
if (directUid) {
|
|
428
|
-
return directUid;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
let taskUrl =
|
|
432
|
-
response?.url ||
|
|
433
|
-
response?.task_url ||
|
|
434
|
-
response?.taskUrl ||
|
|
435
|
-
response?.links?.task;
|
|
436
|
-
if (!taskUrl) {
|
|
437
|
-
const taskId = response?.taskId || response?.task?.id || response?.id;
|
|
438
|
-
if (!taskId) {
|
|
439
|
-
throw new Error(
|
|
440
|
-
`Collection generation did not return a task URL or ID for ${prefix}`
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
taskUrl = `/specs/${specId}/tasks/${taskId}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
for (let attempt = 0; attempt < 45; attempt += 1) {
|
|
447
|
-
await new Promise((resolve) => {
|
|
448
|
-
setTimeout(resolve, 2000);
|
|
449
|
-
});
|
|
450
|
-
const task = await this.request(taskUrl);
|
|
451
|
-
const status = String(task?.status || task?.task?.status || '').toLowerCase();
|
|
452
|
-
if (status === 'completed') {
|
|
453
|
-
const taskUid = extractUid(task);
|
|
454
|
-
if (!taskUid) {
|
|
455
|
-
throw new Error(`Task completed but no UID found for ${prefix}`);
|
|
456
|
-
}
|
|
457
|
-
return taskUid;
|
|
458
|
-
}
|
|
459
|
-
if (status === 'failed') {
|
|
460
|
-
throw new Error(`Task failed for ${prefix}`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
throw new Error(`Collection generation timed out for ${prefix}`);
|
|
465
|
-
},
|
|
466
|
-
{
|
|
467
|
-
maxAttempts: 4,
|
|
468
|
-
delayMs: 2000
|
|
469
|
-
}
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async tagCollection(collectionUid: string, tags: string[]): Promise<void> {
|
|
474
|
-
const normalized = tags
|
|
475
|
-
.map((entry) =>
|
|
476
|
-
String(entry || '')
|
|
477
|
-
.toLowerCase()
|
|
478
|
-
.trim()
|
|
479
|
-
.replace(/[^a-z0-9-]+/g, '-')
|
|
480
|
-
.replace(/^-+|-+$/g, '')
|
|
481
|
-
)
|
|
482
|
-
.filter((entry) => /^[a-z][a-z0-9-]*[a-z0-9]$/.test(entry));
|
|
483
|
-
|
|
484
|
-
if (normalized.length === 0) {
|
|
485
|
-
throw new Error(`No valid tag slugs to apply for collection ${collectionUid}`);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
await this.request(`/collections/${collectionUid}/tags`, {
|
|
489
|
-
method: 'PUT',
|
|
490
|
-
body: JSON.stringify({
|
|
491
|
-
tags: normalized.map((slug) => ({ slug }))
|
|
492
|
-
})
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async injectTests(collectionUid: string, type: 'contract' | 'smoke'): Promise<void> {
|
|
497
|
-
const collectionResponse = await this.request(`/collections/${collectionUid}`);
|
|
498
|
-
const collection = collectionResponse?.collection;
|
|
499
|
-
if (!collection) {
|
|
500
|
-
throw new Error(`Failed to fetch collection ${collectionUid}`);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const smokeTests = [
|
|
504
|
-
"// [Smoke] Auto-generated test assertions",
|
|
505
|
-
"",
|
|
506
|
-
"pm.test('Status code is successful (2xx)', function () {",
|
|
507
|
-
" pm.response.to.be.success;",
|
|
508
|
-
"});",
|
|
509
|
-
"",
|
|
510
|
-
"pm.test('Response time is acceptable', function () {",
|
|
511
|
-
" var threshold = parseInt(pm.environment.get('RESPONSE_TIME_THRESHOLD') || '2000', 10);",
|
|
512
|
-
" pm.expect(pm.response.responseTime).to.be.below(threshold);",
|
|
513
|
-
"});",
|
|
514
|
-
"",
|
|
515
|
-
"pm.test('Response body is not empty', function () {",
|
|
516
|
-
" if (pm.response.code !== 204) {",
|
|
517
|
-
" var body = pm.response.text();",
|
|
518
|
-
" pm.expect(body.length).to.be.above(0);",
|
|
519
|
-
" }",
|
|
520
|
-
"});"
|
|
521
|
-
];
|
|
522
|
-
const contractTests = [
|
|
523
|
-
"// [Contract] Auto-generated contract test assertions",
|
|
524
|
-
"",
|
|
525
|
-
"pm.test('Status code is successful (2xx)', function () {",
|
|
526
|
-
" pm.response.to.be.success;",
|
|
527
|
-
"});",
|
|
528
|
-
"",
|
|
529
|
-
"pm.test('Response time is acceptable', function () {",
|
|
530
|
-
" var threshold = parseInt(pm.environment.get('RESPONSE_TIME_THRESHOLD') || '2000', 10);",
|
|
531
|
-
" pm.expect(pm.response.responseTime).to.be.below(threshold);",
|
|
532
|
-
"});",
|
|
533
|
-
"",
|
|
534
|
-
"pm.test('Response body is not empty', function () {",
|
|
535
|
-
" if (pm.response.code !== 204) {",
|
|
536
|
-
" var body = pm.response.text();",
|
|
537
|
-
" pm.expect(body.length).to.be.above(0);",
|
|
538
|
-
" }",
|
|
539
|
-
"});",
|
|
540
|
-
"",
|
|
541
|
-
"pm.test('Content-Type is application/json', function () {",
|
|
542
|
-
" if (pm.response.code !== 204) {",
|
|
543
|
-
" pm.response.to.have.header('Content-Type');",
|
|
544
|
-
" pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');",
|
|
545
|
-
" }",
|
|
546
|
-
"});",
|
|
547
|
-
"",
|
|
548
|
-
"pm.test('Response is valid JSON', function () {",
|
|
549
|
-
" if (pm.response.code !== 204) {",
|
|
550
|
-
" pm.response.to.be.json;",
|
|
551
|
-
" }",
|
|
552
|
-
"});",
|
|
553
|
-
"",
|
|
554
|
-
"// Validate required fields from response schema",
|
|
555
|
-
"pm.test('Required fields are present', function () {",
|
|
556
|
-
" if (pm.response.code === 204) return;",
|
|
557
|
-
" var jsonData = pm.response.json();",
|
|
558
|
-
" pm.expect(jsonData).to.be.an('object');",
|
|
559
|
-
" var keys = Object.keys(jsonData);",
|
|
560
|
-
" if (keys.length === 1 && Array.isArray(jsonData[keys[0]])) {",
|
|
561
|
-
" pm.expect(jsonData[keys[0]]).to.be.an('array');",
|
|
562
|
-
" }",
|
|
563
|
-
"});",
|
|
564
|
-
"",
|
|
565
|
-
"// Validate response field types (non-null required fields)",
|
|
566
|
-
"pm.test('Field types are correct', function () {",
|
|
567
|
-
" if (pm.response.code === 204) return;",
|
|
568
|
-
" var jsonData = pm.response.json();",
|
|
569
|
-
" Object.keys(jsonData).forEach(function(key) {",
|
|
570
|
-
" pm.expect(jsonData[key]).to.not.be.undefined;",
|
|
571
|
-
" });",
|
|
572
|
-
"});",
|
|
573
|
-
"",
|
|
574
|
-
"(function() {",
|
|
575
|
-
" var status = pm.response.code;",
|
|
576
|
-
" if (status === 204) return; ",
|
|
577
|
-
" try {",
|
|
578
|
-
" var body = pm.response.json();",
|
|
579
|
-
" pm.test('Response body matches expected structure', function () {",
|
|
580
|
-
" pm.expect(typeof body).to.equal('object');",
|
|
581
|
-
" if (status >= 400) {",
|
|
582
|
-
" pm.expect(body).to.have.property('error');",
|
|
583
|
-
" pm.expect(body).to.have.property('message');",
|
|
584
|
-
" }",
|
|
585
|
-
" });",
|
|
586
|
-
" } catch (e) {}",
|
|
587
|
-
"})();"
|
|
588
|
-
];
|
|
589
|
-
|
|
590
|
-
const scriptsToInject = type === 'smoke' ? smokeTests : contractTests;
|
|
591
|
-
const request0Item = {
|
|
592
|
-
name: '00 - Resolve Secrets',
|
|
593
|
-
request: {
|
|
594
|
-
auth: {
|
|
595
|
-
type: 'awsv4',
|
|
596
|
-
awsv4: [
|
|
597
|
-
{ key: 'accessKey', value: '{{AWS_ACCESS_KEY_ID}}' },
|
|
598
|
-
{ key: 'secretKey', value: '{{AWS_SECRET_ACCESS_KEY}}' },
|
|
599
|
-
{ key: 'region', value: '{{AWS_REGION}}' },
|
|
600
|
-
{ key: 'service', value: 'secretsmanager' }
|
|
601
|
-
]
|
|
602
|
-
},
|
|
603
|
-
method: 'POST',
|
|
604
|
-
header: [
|
|
605
|
-
{ key: 'X-Amz-Target', value: 'secretsmanager.GetSecretValue' },
|
|
606
|
-
{ key: 'Content-Type', value: 'application/x-amz-json-1.1' }
|
|
607
|
-
],
|
|
608
|
-
body: {
|
|
609
|
-
mode: 'raw',
|
|
610
|
-
raw: '{"SecretId": "{{AWS_SECRET_NAME}}"}'
|
|
611
|
-
},
|
|
612
|
-
url: {
|
|
613
|
-
raw: 'https://secretsmanager.{{AWS_REGION}}.amazonaws.com',
|
|
614
|
-
protocol: 'https',
|
|
615
|
-
host: ['secretsmanager', '{{AWS_REGION}}', 'amazonaws', 'com']
|
|
616
|
-
}
|
|
617
|
-
},
|
|
618
|
-
event: [
|
|
619
|
-
{
|
|
620
|
-
listen: 'test',
|
|
621
|
-
script: {
|
|
622
|
-
exec: [
|
|
623
|
-
'if (pm.environment.get("CI") === "true") { return; }',
|
|
624
|
-
'const body = pm.response.json();',
|
|
625
|
-
'if (body.SecretString) {',
|
|
626
|
-
' const secrets = JSON.parse(body.SecretString);',
|
|
627
|
-
' Object.entries(secrets).forEach(([k, v]) => pm.collectionVariables.set(k, v));',
|
|
628
|
-
'}'
|
|
629
|
-
]
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
]
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
const injectScripts = (itemNode: any) => {
|
|
636
|
-
if (itemNode.name === '00 - Resolve Secrets') {
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (itemNode.request) {
|
|
641
|
-
itemNode.event = (itemNode.event || []).filter(
|
|
642
|
-
(entry: any) => entry.listen !== 'test'
|
|
643
|
-
);
|
|
644
|
-
itemNode.event.push({
|
|
645
|
-
listen: 'test',
|
|
646
|
-
script: {
|
|
647
|
-
type: 'text/javascript',
|
|
648
|
-
exec: scriptsToInject
|
|
649
|
-
}
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
if (Array.isArray(itemNode.item)) {
|
|
653
|
-
itemNode.item.forEach(injectScripts);
|
|
654
|
-
}
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
if (Array.isArray(collection.item)) {
|
|
658
|
-
// Remove any existing secrets resolver to prevent duplicates on reruns
|
|
659
|
-
collection.item = collection.item.filter(
|
|
660
|
-
(entry: any) => entry.name !== '00 - Resolve Secrets'
|
|
661
|
-
);
|
|
662
|
-
collection.item.forEach(injectScripts);
|
|
663
|
-
} else {
|
|
664
|
-
collection.item = [];
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
collection.item.unshift(request0Item);
|
|
668
|
-
|
|
669
|
-
await this.request(`/collections/${collectionUid}`, {
|
|
670
|
-
method: 'PUT',
|
|
671
|
-
body: JSON.stringify({ collection })
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
async createEnvironment(
|
|
676
|
-
workspaceId: string,
|
|
677
|
-
name: string,
|
|
678
|
-
values: EnvironmentValue[]
|
|
679
|
-
): Promise<string> {
|
|
680
|
-
const response = await this.request(`/environments?workspace=${workspaceId}`, {
|
|
681
|
-
method: 'POST',
|
|
682
|
-
body: JSON.stringify({
|
|
683
|
-
environment: {
|
|
684
|
-
name,
|
|
685
|
-
values
|
|
686
|
-
}
|
|
687
|
-
})
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
const uid = String(response?.environment?.uid || '').trim();
|
|
691
|
-
if (!uid) {
|
|
692
|
-
throw new Error('Environment create did not return a UID');
|
|
693
|
-
}
|
|
694
|
-
return uid;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
async updateEnvironment(
|
|
698
|
-
uid: string,
|
|
699
|
-
name: string,
|
|
700
|
-
values: EnvironmentValue[]
|
|
701
|
-
): Promise<void> {
|
|
702
|
-
await this.request(`/environments/${uid}`, {
|
|
703
|
-
method: 'PUT',
|
|
704
|
-
body: JSON.stringify({
|
|
705
|
-
environment: {
|
|
706
|
-
name,
|
|
707
|
-
values
|
|
708
|
-
}
|
|
709
|
-
})
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async createMonitor(
|
|
714
|
-
workspaceId: string,
|
|
715
|
-
name: string,
|
|
716
|
-
collectionUid: string,
|
|
717
|
-
environmentUid: string
|
|
718
|
-
): Promise<string> {
|
|
719
|
-
const response = await this.request(`/monitors?workspace=${workspaceId}`, {
|
|
720
|
-
method: 'POST',
|
|
721
|
-
body: JSON.stringify({
|
|
722
|
-
monitor: {
|
|
723
|
-
name,
|
|
724
|
-
collection: collectionUid,
|
|
725
|
-
environment: environmentUid,
|
|
726
|
-
schedule: {
|
|
727
|
-
cron: '*/5 * * * *',
|
|
728
|
-
timezone: 'UTC'
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
})
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
const uid = String(response?.monitor?.uid || '').trim();
|
|
735
|
-
if (!uid) {
|
|
736
|
-
throw new Error('Monitor create did not return a UID');
|
|
737
|
-
}
|
|
738
|
-
return uid;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
async createMock(
|
|
742
|
-
workspaceId: string,
|
|
743
|
-
name: string,
|
|
744
|
-
collectionUid: string,
|
|
745
|
-
environmentUid: string
|
|
746
|
-
): Promise<{ uid: string; url: string }> {
|
|
747
|
-
const response = await this.request(`/mocks?workspace=${workspaceId}`, {
|
|
748
|
-
method: 'POST',
|
|
749
|
-
body: JSON.stringify({
|
|
750
|
-
mock: {
|
|
751
|
-
name,
|
|
752
|
-
collection: collectionUid,
|
|
753
|
-
environment: environmentUid,
|
|
754
|
-
private: false
|
|
755
|
-
}
|
|
756
|
-
})
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
const uid = String(response?.mock?.uid || '').trim();
|
|
760
|
-
if (!uid) {
|
|
761
|
-
throw new Error('Mock create did not return a UID');
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return {
|
|
765
|
-
uid,
|
|
766
|
-
url:
|
|
767
|
-
String(response?.mock?.mockUrl || '').trim() ||
|
|
768
|
-
String(response?.mock?.config?.serverResponseId || '').trim()
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
async getCollection(uid: string): Promise<any> {
|
|
773
|
-
const response = await this.request(`/collections/${uid}`);
|
|
774
|
-
return response?.collection;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
async getEnvironment(uid: string): Promise<any> {
|
|
778
|
-
const response = await this.request(`/environments/${uid}`);
|
|
779
|
-
return response?.environment;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async getEnvironments(workspaceId: string): Promise<any[]> {
|
|
783
|
-
const response = await this.request(`/environments?workspace=${workspaceId}`);
|
|
784
|
-
return response?.environments || [];
|
|
785
|
-
}
|
|
786
|
-
}
|