@postman-cse/onboarding-bootstrap 0.9.1

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.
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi, afterEach } from 'vitest';
2
+
3
+ import { retry } from '../src/lib/retry.js';
4
+
5
+ describe('retry', () => {
6
+ afterEach(() => {
7
+ vi.useRealTimers();
8
+ });
9
+
10
+ it('retries failed operations and resolves on a later success', async () => {
11
+ vi.useFakeTimers();
12
+
13
+ const operation = vi
14
+ .fn<() => Promise<string>>()
15
+ .mockRejectedValueOnce(new Error('first failure'))
16
+ .mockResolvedValueOnce('ok');
17
+
18
+ const onRetry = vi.fn();
19
+ const promise = retry(operation, {
20
+ maxAttempts: 3,
21
+ delayMs: 250,
22
+ onRetry
23
+ });
24
+
25
+ await vi.advanceTimersByTimeAsync(250);
26
+
27
+ await expect(promise).resolves.toBe('ok');
28
+ expect(operation).toHaveBeenCalledTimes(2);
29
+ expect(onRetry).toHaveBeenCalledTimes(1);
30
+ expect(onRetry).toHaveBeenCalledWith(
31
+ expect.objectContaining({
32
+ attempt: 1,
33
+ delayMs: 250
34
+ })
35
+ );
36
+ });
37
+
38
+ it('stops retrying when shouldRetry rejects the error', async () => {
39
+ vi.useFakeTimers();
40
+
41
+ const operation = vi
42
+ .fn<() => Promise<string>>()
43
+ .mockRejectedValue(new Error('fatal'));
44
+
45
+ const promise = retry(operation, {
46
+ maxAttempts: 4,
47
+ delayMs: 500,
48
+ shouldRetry: () => false
49
+ });
50
+
51
+ await expect(promise).rejects.toThrow('fatal');
52
+ expect(operation).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { HttpError } from '../src/lib/http-error.js';
4
+ import {
5
+ REDACTED,
6
+ redactSecrets,
7
+ sanitizeHeaders
8
+ } from '../src/lib/secrets.js';
9
+
10
+ describe('secret safety rails', () => {
11
+ it('redacts configured secret values from freeform text', () => {
12
+ const sanitized = redactSecrets(
13
+ 'Authorization: Bearer token-123 and key pmak-secret',
14
+ ['token-123', 'pmak-secret']
15
+ );
16
+
17
+ expect(sanitized).toBe(`Authorization: Bearer ${REDACTED} and key ${REDACTED}`);
18
+ });
19
+
20
+ it('sanitizes headers before surfacing them', () => {
21
+ const headers = sanitizeHeaders(
22
+ {
23
+ Authorization: 'Bearer token-123',
24
+ 'x-api-key': 'pmak-secret',
25
+ 'x-trace-id': 'trace-token-123'
26
+ },
27
+ ['token-123', 'pmak-secret']
28
+ );
29
+
30
+ expect(headers).toEqual({
31
+ authorization: REDACTED,
32
+ 'x-api-key': REDACTED,
33
+ 'x-trace-id': `trace-${REDACTED}`
34
+ });
35
+ });
36
+
37
+ it('builds sanitized HTTP diagnostics without leaking token material', () => {
38
+ const error = new HttpError({
39
+ method: 'POST',
40
+ url: 'https://example.test/resource?token=token-123',
41
+ status: 401,
42
+ statusText: 'Unauthorized',
43
+ requestHeaders: {
44
+ Authorization: 'Bearer token-123',
45
+ 'x-api-key': 'pmak-secret'
46
+ },
47
+ responseBody: 'token-123 rejected with api key pmak-secret',
48
+ secretValues: ['token-123', 'pmak-secret']
49
+ });
50
+
51
+ expect(error.message).not.toContain('token-123');
52
+ expect(error.message).not.toContain('pmak-secret');
53
+ expect(error.toJSON()).toEqual({
54
+ method: 'POST',
55
+ name: 'HttpError',
56
+ requestHeaders: {
57
+ authorization: REDACTED,
58
+ 'x-api-key': REDACTED
59
+ },
60
+ responseBody: `${REDACTED} rejected with api key ${REDACTED}`,
61
+ status: 401,
62
+ statusText: 'Unauthorized',
63
+ url: `https://example.test/resource?token=${REDACTED}`
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,301 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ chooseCanonicalWorkspace,
5
+ resolveCanonicalWorkspaceSelection
6
+ } from '../src/lib/postman/workspace-selection.js';
7
+ import { normalizeGitRepoUrl } from '../src/lib/postman/postman-assets-client.js';
8
+
9
+ describe('normalizeGitRepoUrl', () => {
10
+ it('returns empty string for empty input', () => {
11
+ expect(normalizeGitRepoUrl('')).toBe('');
12
+ expect(normalizeGitRepoUrl(null)).toBe('');
13
+ expect(normalizeGitRepoUrl(undefined)).toBe('');
14
+ });
15
+
16
+ it('normalizes GitHub HTTPS URLs', () => {
17
+ expect(normalizeGitRepoUrl('https://github.com/Postman-CS/my-repo')).toBe(
18
+ 'https://github.com/postman-cs/my-repo'
19
+ );
20
+ });
21
+
22
+ it('normalizes GitLab HTTPS URLs', () => {
23
+ expect(normalizeGitRepoUrl('https://gitlab.com/Org-Name/My-Repo')).toBe(
24
+ 'https://gitlab.com/org-name/my-repo'
25
+ );
26
+ });
27
+
28
+ it('strips .git from GitLab URLs', () => {
29
+ expect(normalizeGitRepoUrl('https://gitlab.com/org/repo.git')).toBe(
30
+ 'https://gitlab.com/org/repo'
31
+ );
32
+ });
33
+
34
+ it('converts GitLab SSH URLs to HTTPS', () => {
35
+ expect(normalizeGitRepoUrl('git@gitlab.com:org/repo.git')).toBe(
36
+ 'https://gitlab.com/org/repo'
37
+ );
38
+ });
39
+
40
+ it('handles self-hosted GitLab instances', () => {
41
+ expect(normalizeGitRepoUrl('https://git.example.com/team/project.git')).toBe(
42
+ 'https://git.example.com/team/project'
43
+ );
44
+ expect(normalizeGitRepoUrl('git@git.example.com:team/project.git')).toBe(
45
+ 'https://git.example.com/team/project'
46
+ );
47
+ });
48
+
49
+ it('strips trailing .git from GitHub URLs', () => {
50
+ expect(normalizeGitRepoUrl('https://github.com/postman-cs/my-repo.git')).toBe(
51
+ 'https://github.com/postman-cs/my-repo'
52
+ );
53
+ });
54
+
55
+ it('converts GitHub SSH URLs to HTTPS', () => {
56
+ expect(normalizeGitRepoUrl('git@github.com:postman-cs/my-repo.git')).toBe(
57
+ 'https://github.com/postman-cs/my-repo'
58
+ );
59
+ });
60
+ });
61
+
62
+ describe('chooseCanonicalWorkspace', () => {
63
+ const repoUrl = 'https://github.com/postman-cs/core-payments';
64
+
65
+ it('returns create when no matching workspaces exist', () => {
66
+ const result = chooseCanonicalWorkspace({
67
+ repoUrl,
68
+ matchingWorkspaces: []
69
+ });
70
+ expect(result).toEqual({ type: 'create' });
71
+ });
72
+
73
+ it('returns existing with linked_match when exactly one workspace is linked to the repo', () => {
74
+ const result = chooseCanonicalWorkspace({
75
+ repoUrl,
76
+ matchingWorkspaces: [
77
+ { id: 'ws-abc', linkedRepoUrl: repoUrl }
78
+ ]
79
+ });
80
+ expect(result).toEqual({
81
+ type: 'existing',
82
+ workspaceId: 'ws-abc',
83
+ source: 'linked_match',
84
+ warning: undefined
85
+ });
86
+ });
87
+
88
+ it('emits a warning when linked workspace differs from repo var workspace', () => {
89
+ const result = chooseCanonicalWorkspace({
90
+ repoUrl,
91
+ repoWorkspaceId: 'ws-old',
92
+ matchingWorkspaces: [
93
+ { id: 'ws-new', linkedRepoUrl: repoUrl }
94
+ ]
95
+ });
96
+ expect(result.type).toBe('existing');
97
+ if (result.type === 'existing') {
98
+ expect(result.workspaceId).toBe('ws-new');
99
+ expect(result.source).toBe('linked_match');
100
+ expect(result.warning).toContain('ws-old');
101
+ expect(result.warning).toContain('ws-new');
102
+ }
103
+ });
104
+
105
+ it('returns manual_review when multiple workspaces are linked to the same repo', () => {
106
+ const result = chooseCanonicalWorkspace({
107
+ repoUrl,
108
+ matchingWorkspaces: [
109
+ { id: 'ws-1', linkedRepoUrl: repoUrl },
110
+ { id: 'ws-2', linkedRepoUrl: repoUrl }
111
+ ]
112
+ });
113
+ expect(result.type).toBe('manual_review');
114
+ if (result.type === 'manual_review') {
115
+ expect(result.reason).toContain('ws-1');
116
+ expect(result.reason).toContain('ws-2');
117
+ }
118
+ });
119
+
120
+ it('keeps repo var workspace when multiple linked workspaces exist and one matches', () => {
121
+ const result = chooseCanonicalWorkspace({
122
+ repoUrl,
123
+ repoWorkspaceId: 'ws-1',
124
+ matchingWorkspaces: [
125
+ { id: 'ws-1', linkedRepoUrl: repoUrl },
126
+ { id: 'ws-2', linkedRepoUrl: repoUrl }
127
+ ]
128
+ });
129
+ expect(result.type).toBe('existing');
130
+ if (result.type === 'existing') {
131
+ expect(result.workspaceId).toBe('ws-1');
132
+ expect(result.source).toBe('linked_match');
133
+ }
134
+ });
135
+
136
+ it('returns existing with repo_var when no linked match but repo var is set', () => {
137
+ const result = chooseCanonicalWorkspace({
138
+ repoWorkspaceId: 'ws-3',
139
+ repoUrl,
140
+ matchingWorkspaces: []
141
+ });
142
+
143
+ expect(result).toEqual({
144
+ type: 'existing',
145
+ workspaceId: 'ws-3',
146
+ source: 'repo_var'
147
+ });
148
+ });
149
+
150
+ it('returns create when repo var workspace matches by name but is linked to a different repository', () => {
151
+ const result = chooseCanonicalWorkspace({
152
+ repoWorkspaceId: 'ws-stale',
153
+ repoUrl,
154
+ matchingWorkspaces: [
155
+ {
156
+ id: 'ws-stale',
157
+ linkedRepoUrl: 'https://github.com/postman-cs/different-repo'
158
+ }
159
+ ]
160
+ });
161
+
162
+ expect(result).toEqual({ type: 'create' });
163
+ });
164
+
165
+ it('returns existing with name_match when no linked match and no repo var', () => {
166
+ const result = chooseCanonicalWorkspace({
167
+ repoUrl,
168
+ matchingWorkspaces: [{ id: 'ws-7' }]
169
+ });
170
+ expect(result).toEqual({
171
+ type: 'existing',
172
+ workspaceId: 'ws-7',
173
+ source: 'name_match'
174
+ });
175
+ });
176
+
177
+ it('picks the lexicographically first workspace on name_match when multiple exist', () => {
178
+ const result = chooseCanonicalWorkspace({
179
+ repoUrl,
180
+ matchingWorkspaces: [
181
+ { id: 'ws-zzz', linkedRepoUrl: null },
182
+ { id: 'ws-aaa', linkedRepoUrl: null }
183
+ ]
184
+ });
185
+ expect(result.type).toBe('existing');
186
+ if (result.type === 'existing') {
187
+ expect(result.workspaceId).toBe('ws-aaa');
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('resolveCanonicalWorkspaceSelection', () => {
193
+ const repoUrl = 'https://github.com/postman-cs/core-payments';
194
+ const workspaceName = '[AF] core-payments';
195
+
196
+ it('returns create when no workspaces match by name', async () => {
197
+ const postman = {
198
+ findWorkspacesByName: vi.fn().mockResolvedValue([]),
199
+ getWorkspaceGitRepoUrl: vi.fn()
200
+ };
201
+
202
+ const result = await resolveCanonicalWorkspaceSelection({
203
+ postman,
204
+ workspaceName,
205
+ repoUrl,
206
+ teamId: 'team-123',
207
+ accessToken: 'token-abc'
208
+ });
209
+
210
+ expect(result).toEqual({ type: 'create' });
211
+ expect(postman.getWorkspaceGitRepoUrl).not.toHaveBeenCalled();
212
+ });
213
+
214
+ it('fetches git repo URL even when only one workspace matches by name', async () => {
215
+ const postman = {
216
+ findWorkspacesByName: vi.fn().mockResolvedValue([{ id: 'ws-single', name: workspaceName }]),
217
+ getWorkspaceGitRepoUrl: vi.fn().mockResolvedValue(repoUrl)
218
+ };
219
+
220
+ const result = await resolveCanonicalWorkspaceSelection({
221
+ postman,
222
+ workspaceName,
223
+ repoUrl,
224
+ teamId: 'team-123',
225
+ accessToken: 'token-abc'
226
+ });
227
+
228
+ expect(result.type).toBe('existing');
229
+ expect(postman.getWorkspaceGitRepoUrl).toHaveBeenCalled();
230
+ });
231
+
232
+ it('fetches git repo URLs when multiple workspaces match by name', async () => {
233
+ const postman = {
234
+ findWorkspacesByName: vi.fn().mockResolvedValue([
235
+ { id: 'ws-1', name: workspaceName },
236
+ { id: 'ws-2', name: workspaceName }
237
+ ]),
238
+ getWorkspaceGitRepoUrl: vi.fn().mockImplementation(async (id: string) => {
239
+ if (id === 'ws-1') return repoUrl;
240
+ return null;
241
+ })
242
+ };
243
+
244
+ const result = await resolveCanonicalWorkspaceSelection({
245
+ postman,
246
+ workspaceName,
247
+ repoUrl,
248
+ teamId: 'team-123',
249
+ accessToken: 'token-abc'
250
+ });
251
+
252
+ expect(result.type).toBe('existing');
253
+ if (result.type === 'existing') {
254
+ expect(result.workspaceId).toBe('ws-1');
255
+ expect(result.source).toBe('linked_match');
256
+ }
257
+ expect(postman.getWorkspaceGitRepoUrl).toHaveBeenCalledTimes(2);
258
+ });
259
+
260
+ it('falls back to repoWorkspaceId when workspace lookup fails', async () => {
261
+ const warn = vi.fn();
262
+ const postman = {
263
+ findWorkspacesByName: vi.fn().mockRejectedValue(new Error('network error')),
264
+ getWorkspaceGitRepoUrl: vi.fn()
265
+ };
266
+
267
+ const result = await resolveCanonicalWorkspaceSelection({
268
+ postman,
269
+ workspaceName,
270
+ repoWorkspaceId: 'ws-fallback',
271
+ repoUrl,
272
+ teamId: 'team-123',
273
+ accessToken: 'token-abc',
274
+ warn
275
+ });
276
+
277
+ expect(result.type).toBe('existing');
278
+ if (result.type === 'existing') {
279
+ expect(result.workspaceId).toBe('ws-fallback');
280
+ expect(result.source).toBe('repo_var');
281
+ }
282
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('ws-fallback'));
283
+ });
284
+
285
+ it('rethrows workspace lookup error when no repoWorkspaceId fallback exists', async () => {
286
+ const postman = {
287
+ findWorkspacesByName: vi.fn().mockRejectedValue(new Error('network error')),
288
+ getWorkspaceGitRepoUrl: vi.fn()
289
+ };
290
+
291
+ await expect(
292
+ resolveCanonicalWorkspaceSelection({
293
+ postman,
294
+ workspaceName,
295
+ repoUrl,
296
+ teamId: 'team-123',
297
+ accessToken: 'token-abc'
298
+ })
299
+ ).rejects.toThrow('network error');
300
+ });
301
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "workspace-id": "ws-123",
3
+ "workspace-url": "https://go.postman.co/workspace/ws-123",
4
+ "workspace-name": "[AF] core-payments",
5
+ "spec-id": "spec-123",
6
+ "baseline-collection-id": "col-baseline",
7
+ "smoke-collection-id": "col-smoke",
8
+ "contract-collection-id": "col-contract",
9
+ "collections-json": "{\"baseline\":\"col-baseline\",\"smoke\":\"col-smoke\",\"contract\":\"col-contract\"}",
10
+ "lint-summary-json": "{\"errors\":0,\"total\":0,\"violations\":[],\"warnings\":0}"
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "skipLibCheck": true,
10
+ "types": [
11
+ "node",
12
+ "vitest/globals"
13
+ ]
14
+ },
15
+ "include": [
16
+ "src/**/*.ts",
17
+ "tests/**/*.ts",
18
+ "vitest.config.ts"
19
+ ]
20
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts']
7
+ }
8
+ });