@intelmesh/sdk 0.1.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/.github/scripts/compute-disttag.sh +47 -0
- package/.github/workflows/release.yml +206 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +2 -0
- package/.prettierrc +8 -0
- package/CLAUDE.md +37 -0
- package/LICENSE +21 -0
- package/commitlint.config.cjs +3 -0
- package/dist/index.d.ts +1293 -0
- package/dist/index.js +1651 -0
- package/docs/superpowers/plans/2026-04-10-release-pipeline.md +798 -0
- package/docs/superpowers/specs/2026-04-10-release-pipeline-design.md +309 -0
- package/eslint.config.mjs +38 -0
- package/package.json +72 -0
- package/src/builders/event.ts +72 -0
- package/src/builders/rule.ts +143 -0
- package/src/client/errors.ts +171 -0
- package/src/client/http.ts +209 -0
- package/src/client/intelmesh.ts +57 -0
- package/src/client/pagination.ts +50 -0
- package/src/generated/types.ts +11 -0
- package/src/index.ts +106 -0
- package/src/provision/index.ts +6 -0
- package/src/provision/provisioner.ts +326 -0
- package/src/provision/rule-builder.ts +193 -0
- package/src/resources/apikeys.ts +63 -0
- package/src/resources/audit.ts +29 -0
- package/src/resources/evaluations.ts +38 -0
- package/src/resources/events.ts +61 -0
- package/src/resources/lists.ts +91 -0
- package/src/resources/phases.ts +71 -0
- package/src/resources/rules.ts +98 -0
- package/src/resources/scopes.ts +71 -0
- package/src/resources/scores.ts +63 -0
- package/src/testkit/assertion.ts +76 -0
- package/src/testkit/harness.ts +252 -0
- package/src/testkit/index.ts +7 -0
- package/src/types.ts +330 -0
- package/tests/client/errors.test.ts +159 -0
- package/tests/provision/provisioner.test.ts +311 -0
- package/tests/scripts/compute-disttag.test.ts +178 -0
- package/tests/testkit/harness.test.ts +291 -0
- package/tsconfig.eslint.json +8 -0
- package/tsconfig.json +29 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ForbiddenError,
|
|
4
|
+
IntelMeshError,
|
|
5
|
+
InternalError,
|
|
6
|
+
isForbidden,
|
|
7
|
+
isIntelMeshError,
|
|
8
|
+
isNetwork,
|
|
9
|
+
isNotFound,
|
|
10
|
+
isUnauthorized,
|
|
11
|
+
isValidation,
|
|
12
|
+
mapStatusToError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ParseError,
|
|
16
|
+
UnavailableError,
|
|
17
|
+
UnauthorizedError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
} from '../../src/client/errors.js';
|
|
20
|
+
|
|
21
|
+
describe('IntelMeshError', () => {
|
|
22
|
+
it('stores status, code, and message', () => {
|
|
23
|
+
const err = new IntelMeshError('test', 500, 'ERR');
|
|
24
|
+
expect(err.message).toBe('test');
|
|
25
|
+
expect(err.status).toBe(500);
|
|
26
|
+
expect(err.code).toBe('ERR');
|
|
27
|
+
expect(err.name).toBe('IntelMeshError');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('extends Error', () => {
|
|
31
|
+
const err = new IntelMeshError('test', 500, 'ERR');
|
|
32
|
+
expect(err).toBeInstanceOf(Error);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('Typed error subclasses', () => {
|
|
37
|
+
it('ValidationError has status 400', () => {
|
|
38
|
+
const err = new ValidationError('bad input');
|
|
39
|
+
expect(err.status).toBe(400);
|
|
40
|
+
expect(err.name).toBe('ValidationError');
|
|
41
|
+
expect(err).toBeInstanceOf(IntelMeshError);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('NotFoundError has status 404', () => {
|
|
45
|
+
const err = new NotFoundError('missing');
|
|
46
|
+
expect(err.status).toBe(404);
|
|
47
|
+
expect(err.name).toBe('NotFoundError');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('UnauthorizedError has status 401', () => {
|
|
51
|
+
const err = new UnauthorizedError('no auth');
|
|
52
|
+
expect(err.status).toBe(401);
|
|
53
|
+
expect(err.name).toBe('UnauthorizedError');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ForbiddenError has status 403', () => {
|
|
57
|
+
const err = new ForbiddenError('denied');
|
|
58
|
+
expect(err.status).toBe(403);
|
|
59
|
+
expect(err.name).toBe('ForbiddenError');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('InternalError has status 500', () => {
|
|
63
|
+
const err = new InternalError('boom');
|
|
64
|
+
expect(err.status).toBe(500);
|
|
65
|
+
expect(err.name).toBe('InternalError');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('UnavailableError has status 503', () => {
|
|
69
|
+
const err = new UnavailableError('down');
|
|
70
|
+
expect(err.status).toBe(503);
|
|
71
|
+
expect(err.name).toBe('UnavailableError');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('NetworkError has status 0', () => {
|
|
75
|
+
const err = new NetworkError('offline');
|
|
76
|
+
expect(err.status).toBe(0);
|
|
77
|
+
expect(err.code).toBe('NETWORK_ERROR');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('ParseError has status 0', () => {
|
|
81
|
+
const err = new ParseError('bad json');
|
|
82
|
+
expect(err.status).toBe(0);
|
|
83
|
+
expect(err.code).toBe('PARSE_ERROR');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('Type guard helpers', () => {
|
|
88
|
+
it('isNotFound identifies NotFoundError', () => {
|
|
89
|
+
expect(isNotFound(new NotFoundError('x'))).toBe(true);
|
|
90
|
+
expect(isNotFound(new ValidationError('x'))).toBe(false);
|
|
91
|
+
expect(isNotFound(new Error('x'))).toBe(false);
|
|
92
|
+
expect(isNotFound(null)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('isValidation identifies ValidationError', () => {
|
|
96
|
+
expect(isValidation(new ValidationError('x'))).toBe(true);
|
|
97
|
+
expect(isValidation(new NotFoundError('x'))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('isUnauthorized identifies UnauthorizedError', () => {
|
|
101
|
+
expect(isUnauthorized(new UnauthorizedError('x'))).toBe(true);
|
|
102
|
+
expect(isUnauthorized(new ForbiddenError('x'))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('isForbidden identifies ForbiddenError', () => {
|
|
106
|
+
expect(isForbidden(new ForbiddenError('x'))).toBe(true);
|
|
107
|
+
expect(isForbidden(new UnauthorizedError('x'))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('isNetwork identifies NetworkError', () => {
|
|
111
|
+
expect(isNetwork(new NetworkError('x'))).toBe(true);
|
|
112
|
+
expect(isNetwork(new ParseError('x'))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('isIntelMeshError matches all subclasses', () => {
|
|
116
|
+
expect(isIntelMeshError(new ValidationError('x'))).toBe(true);
|
|
117
|
+
expect(isIntelMeshError(new NetworkError('x'))).toBe(true);
|
|
118
|
+
expect(isIntelMeshError(new Error('x'))).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('mapStatusToError', () => {
|
|
123
|
+
it('maps 400 to ValidationError', () => {
|
|
124
|
+
const err = mapStatusToError(400, 'bad', 'INVALID');
|
|
125
|
+
expect(err).toBeInstanceOf(ValidationError);
|
|
126
|
+
expect(err.code).toBe('INVALID');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('maps 401 to UnauthorizedError', () => {
|
|
130
|
+
const err = mapStatusToError(401, 'no auth', 'UNAUTH');
|
|
131
|
+
expect(err).toBeInstanceOf(UnauthorizedError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('maps 403 to ForbiddenError', () => {
|
|
135
|
+
const err = mapStatusToError(403, 'denied', 'FORBIDDEN');
|
|
136
|
+
expect(err).toBeInstanceOf(ForbiddenError);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('maps 404 to NotFoundError', () => {
|
|
140
|
+
const err = mapStatusToError(404, 'gone', 'MISSING');
|
|
141
|
+
expect(err).toBeInstanceOf(NotFoundError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('maps 503 to UnavailableError', () => {
|
|
145
|
+
const err = mapStatusToError(503, 'down', 'UNAVAIL');
|
|
146
|
+
expect(err).toBeInstanceOf(UnavailableError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('maps 502 to InternalError', () => {
|
|
150
|
+
const err = mapStatusToError(502, 'gateway', 'GW');
|
|
151
|
+
expect(err).toBeInstanceOf(InternalError);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('maps unknown status to IntelMeshError', () => {
|
|
155
|
+
const err = mapStatusToError(429, 'rate', 'RATE');
|
|
156
|
+
expect(err).toBeInstanceOf(IntelMeshError);
|
|
157
|
+
expect(err.status).toBe(429);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Provisioner unit tests — mock fetch, no network calls
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { IntelMesh } from '../../src/client/intelmesh.js';
|
|
7
|
+
import { Provisioner } from '../../src/provision/provisioner.js';
|
|
8
|
+
|
|
9
|
+
/** Tracks resource creation and deletion counts. */
|
|
10
|
+
interface MockState {
|
|
11
|
+
created: Record<string, number>;
|
|
12
|
+
deleted: Record<string, number>;
|
|
13
|
+
idSeq: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handles POST requests in the mock fetch.
|
|
18
|
+
* @param url
|
|
19
|
+
* @param state
|
|
20
|
+
*/
|
|
21
|
+
// eslint-disable-next-line max-lines-per-function -- each branch is a simple JSON fixture; splitting further would obscure test data
|
|
22
|
+
function handlePost(url: string, state: MockState): Response | null {
|
|
23
|
+
if (url.includes('/api/v1/phases')) {
|
|
24
|
+
state.created['phases'] = (state.created['phases'] ?? 0) + 1;
|
|
25
|
+
state.idSeq++;
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
data: {
|
|
28
|
+
id: `phase-${String(state.idSeq)}`,
|
|
29
|
+
name: 'p',
|
|
30
|
+
position: 1,
|
|
31
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (url.includes('/api/v1/scopes')) {
|
|
36
|
+
state.created['scopes'] = (state.created['scopes'] ?? 0) + 1;
|
|
37
|
+
state.idSeq++;
|
|
38
|
+
return jsonResponse({
|
|
39
|
+
data: {
|
|
40
|
+
id: `scope-${String(state.idSeq)}`,
|
|
41
|
+
name: 's',
|
|
42
|
+
json_path: '$.x',
|
|
43
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (url.includes('/api/v1/lists')) {
|
|
48
|
+
state.created['lists'] = (state.created['lists'] ?? 0) + 1;
|
|
49
|
+
state.idSeq++;
|
|
50
|
+
return jsonResponse({
|
|
51
|
+
data: {
|
|
52
|
+
id: `list-${String(state.idSeq)}`,
|
|
53
|
+
name: 'l',
|
|
54
|
+
description: '',
|
|
55
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
56
|
+
updated_at: '2025-01-01T00:00:00Z',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (url.includes('/api/v1/rules')) {
|
|
61
|
+
state.created['rules'] = (state.created['rules'] ?? 0) + 1;
|
|
62
|
+
state.idSeq++;
|
|
63
|
+
return jsonResponse({
|
|
64
|
+
data: {
|
|
65
|
+
id: `rule-${String(state.idSeq)}`,
|
|
66
|
+
name: 'r',
|
|
67
|
+
phase_id: 'p1',
|
|
68
|
+
priority: 1,
|
|
69
|
+
expression: 'true',
|
|
70
|
+
applicable_when: '',
|
|
71
|
+
actions: {},
|
|
72
|
+
enabled: true,
|
|
73
|
+
dry_run: false,
|
|
74
|
+
current_version_id: 'v1',
|
|
75
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
76
|
+
updated_at: '2025-01-01T00:00:00Z',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Handles DELETE requests in the mock fetch.
|
|
85
|
+
* @param url
|
|
86
|
+
* @param state
|
|
87
|
+
*/
|
|
88
|
+
function handleDelete(url: string, state: MockState): Response | null {
|
|
89
|
+
if (url.includes('/api/v1/phases/')) {
|
|
90
|
+
state.deleted['phases'] = (state.deleted['phases'] ?? 0) + 1;
|
|
91
|
+
return jsonResponse({ data: null });
|
|
92
|
+
}
|
|
93
|
+
if (url.includes('/api/v1/scopes/')) {
|
|
94
|
+
state.deleted['scopes'] = (state.deleted['scopes'] ?? 0) + 1;
|
|
95
|
+
return jsonResponse({ data: null });
|
|
96
|
+
}
|
|
97
|
+
if (url.includes('/api/v1/lists/')) {
|
|
98
|
+
state.deleted['lists'] = (state.deleted['lists'] ?? 0) + 1;
|
|
99
|
+
return jsonResponse({ data: null });
|
|
100
|
+
}
|
|
101
|
+
if (url.includes('/api/v1/rules/')) {
|
|
102
|
+
state.deleted['rules'] = (state.deleted['rules'] ?? 0) + 1;
|
|
103
|
+
return jsonResponse({ data: null });
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Creates a mock fetch that simulates the IntelMesh API.
|
|
110
|
+
* @param state
|
|
111
|
+
*/
|
|
112
|
+
function createMockFetch(state: MockState): typeof globalThis.fetch {
|
|
113
|
+
return vi.fn((input: string | URL | Request, init?: RequestInit) => {
|
|
114
|
+
const url =
|
|
115
|
+
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
116
|
+
const method = init?.method ?? 'GET';
|
|
117
|
+
|
|
118
|
+
if (method === 'POST') {
|
|
119
|
+
const res = handlePost(url, state);
|
|
120
|
+
if (res) return Promise.resolve(res);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (method === 'DELETE') {
|
|
124
|
+
const res = handleDelete(url, state);
|
|
125
|
+
if (res) return Promise.resolve(res);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Promise.resolve(new Response('Not Found', { status: 404 }));
|
|
129
|
+
}) as typeof globalThis.fetch;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates a JSON Response.
|
|
134
|
+
* @param body
|
|
135
|
+
*/
|
|
136
|
+
function jsonResponse(body: unknown): Response {
|
|
137
|
+
return new Response(JSON.stringify(body), {
|
|
138
|
+
status: 200,
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// eslint-disable-next-line max-lines-per-function -- describe block spans many test cases; splitting into multiple files would obscure test coverage
|
|
144
|
+
describe('Provisioner', () => {
|
|
145
|
+
let state: MockState;
|
|
146
|
+
let client: IntelMesh;
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
state = { created: {}, deleted: {}, idSeq: 0 };
|
|
150
|
+
client = new IntelMesh({
|
|
151
|
+
baseUrl: 'http://localhost:8080',
|
|
152
|
+
apiKey: 'test-key',
|
|
153
|
+
fetch: createMockFetch(state),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('applies phases, scopes, lists, and rules in order', async () => {
|
|
158
|
+
const p = new Provisioner(client)
|
|
159
|
+
.phase('screening', 1)
|
|
160
|
+
.scope('client_uuid', 'event.metadata.client_uuid')
|
|
161
|
+
.list('blocklist')
|
|
162
|
+
.rule('block-rule')
|
|
163
|
+
.inPhase('screening')
|
|
164
|
+
.priority(1)
|
|
165
|
+
.when('true')
|
|
166
|
+
.decide('block', 'critical')
|
|
167
|
+
.halt()
|
|
168
|
+
.done();
|
|
169
|
+
|
|
170
|
+
await p.apply();
|
|
171
|
+
|
|
172
|
+
expect(state.created['phases']).toBe(1);
|
|
173
|
+
expect(state.created['scopes']).toBe(1);
|
|
174
|
+
expect(state.created['lists']).toBe(1);
|
|
175
|
+
expect(state.created['rules']).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('stores resolved IDs after apply', async () => {
|
|
179
|
+
const p = new Provisioner(client)
|
|
180
|
+
.phase('screening', 1)
|
|
181
|
+
.scope('client_uuid', 'event.metadata.client_uuid')
|
|
182
|
+
.list('blocklist')
|
|
183
|
+
.rule('block-rule')
|
|
184
|
+
.inPhase('screening')
|
|
185
|
+
.priority(1)
|
|
186
|
+
.when('true')
|
|
187
|
+
.done();
|
|
188
|
+
|
|
189
|
+
await p.apply();
|
|
190
|
+
|
|
191
|
+
expect(p.phaseId('screening')).toBeTruthy();
|
|
192
|
+
expect(p.scopeId('client_uuid')).toBeTruthy();
|
|
193
|
+
expect(p.listId('blocklist')).toBeTruthy();
|
|
194
|
+
expect(p.ruleId('block-rule')).toBeTruthy();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('returns empty string for unknown names', () => {
|
|
198
|
+
const p = new Provisioner(client);
|
|
199
|
+
expect(p.phaseId('unknown')).toBe('');
|
|
200
|
+
expect(p.scopeId('unknown')).toBe('');
|
|
201
|
+
expect(p.listId('unknown')).toBe('');
|
|
202
|
+
expect(p.ruleId('unknown')).toBe('');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('throws when rule references missing phase', async () => {
|
|
206
|
+
const p = new Provisioner(client)
|
|
207
|
+
.rule('orphan-rule')
|
|
208
|
+
.inPhase('nonexistent')
|
|
209
|
+
.priority(1)
|
|
210
|
+
.when('true')
|
|
211
|
+
.done();
|
|
212
|
+
|
|
213
|
+
await expect(p.apply()).rejects.toThrow('phase not found');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('tears down resources in reverse order', async () => {
|
|
217
|
+
const p = new Provisioner(client)
|
|
218
|
+
.phase('screening', 1)
|
|
219
|
+
.scope('client_uuid', 'event.metadata.client_uuid')
|
|
220
|
+
.list('blocklist')
|
|
221
|
+
.rule('block-rule')
|
|
222
|
+
.inPhase('screening')
|
|
223
|
+
.priority(1)
|
|
224
|
+
.when('true')
|
|
225
|
+
.done();
|
|
226
|
+
|
|
227
|
+
await p.apply();
|
|
228
|
+
await p.teardown();
|
|
229
|
+
|
|
230
|
+
expect(state.deleted['rules']).toBe(1);
|
|
231
|
+
expect(state.deleted['lists']).toBe(1);
|
|
232
|
+
expect(state.deleted['scopes']).toBe(1);
|
|
233
|
+
expect(state.deleted['phases']).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('supports multiple phases', async () => {
|
|
237
|
+
const p = new Provisioner(client)
|
|
238
|
+
.phase('screening', 1)
|
|
239
|
+
.phase('scoring', 2)
|
|
240
|
+
.phase('decision', 3);
|
|
241
|
+
|
|
242
|
+
await p.apply();
|
|
243
|
+
|
|
244
|
+
expect(state.created['phases']).toBe(3);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('supports rule with score delta', async () => {
|
|
248
|
+
const p = new Provisioner(client)
|
|
249
|
+
.phase('scoring', 1)
|
|
250
|
+
.rule('add-score')
|
|
251
|
+
.inPhase('scoring')
|
|
252
|
+
.priority(1)
|
|
253
|
+
.when('true')
|
|
254
|
+
.addScore(10)
|
|
255
|
+
.continue()
|
|
256
|
+
.done();
|
|
257
|
+
|
|
258
|
+
await p.apply();
|
|
259
|
+
|
|
260
|
+
expect(state.created['rules']).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('supports rule with list mutation', async () => {
|
|
264
|
+
const p = new Provisioner(client)
|
|
265
|
+
.phase('scoring', 1)
|
|
266
|
+
.list('med_blocked')
|
|
267
|
+
.rule('med-add')
|
|
268
|
+
.inPhase('scoring')
|
|
269
|
+
.priority(1)
|
|
270
|
+
.applicableWhen("event.type == 'bacen.med.add'")
|
|
271
|
+
.when('true')
|
|
272
|
+
.mutateList('list.add', 'med_blocked', 'event.metadata.client_uuid')
|
|
273
|
+
.continue()
|
|
274
|
+
.done();
|
|
275
|
+
|
|
276
|
+
await p.apply();
|
|
277
|
+
|
|
278
|
+
expect(state.created['lists']).toBe(1);
|
|
279
|
+
expect(state.created['rules']).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('supports dry-run rules', async () => {
|
|
283
|
+
const p = new Provisioner(client)
|
|
284
|
+
.phase('screening', 1)
|
|
285
|
+
.rule('dry-rule')
|
|
286
|
+
.inPhase('screening')
|
|
287
|
+
.priority(1)
|
|
288
|
+
.when('true')
|
|
289
|
+
.dryRun()
|
|
290
|
+
.done();
|
|
291
|
+
|
|
292
|
+
await p.apply();
|
|
293
|
+
|
|
294
|
+
expect(state.created['rules']).toBe(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('supports skip-phase flow', async () => {
|
|
298
|
+
const p = new Provisioner(client)
|
|
299
|
+
.phase('screening', 1)
|
|
300
|
+
.rule('skip-rule')
|
|
301
|
+
.inPhase('screening')
|
|
302
|
+
.priority(1)
|
|
303
|
+
.when('true')
|
|
304
|
+
.skipPhase()
|
|
305
|
+
.done();
|
|
306
|
+
|
|
307
|
+
await p.apply();
|
|
308
|
+
|
|
309
|
+
expect(state.created['rules']).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const scriptPath = resolve(here, '../../.github/scripts/compute-disttag.sh');
|
|
10
|
+
|
|
11
|
+
interface ScriptOutput {
|
|
12
|
+
version: string;
|
|
13
|
+
disttag: string;
|
|
14
|
+
is_prerelease: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RawResult {
|
|
18
|
+
status: number;
|
|
19
|
+
stderr: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseOutput(raw: string): ScriptOutput {
|
|
23
|
+
const map = new Map<string, string>();
|
|
24
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
25
|
+
const idx = line.indexOf('=');
|
|
26
|
+
if (idx === -1) continue;
|
|
27
|
+
map.set(line.slice(0, idx), line.slice(idx + 1));
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
version: map.get('version') ?? '',
|
|
31
|
+
disttag: map.get('disttag') ?? '',
|
|
32
|
+
is_prerelease: map.get('is_prerelease') ?? '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runScriptRaw(env: Record<string, string>): RawResult {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'disttag-'));
|
|
38
|
+
// Ensure we do NOT inherit GITHUB_REF_NAME or GITHUB_OUTPUT from
|
|
39
|
+
// the parent process — compose the env from scratch with only
|
|
40
|
+
// PATH (needed for bash discovery) and whatever the caller passes.
|
|
41
|
+
const cleanEnv: Record<string, string> = { PATH: process.env.PATH ?? '' };
|
|
42
|
+
Object.assign(cleanEnv, env);
|
|
43
|
+
const result = spawnSync('bash', [scriptPath], {
|
|
44
|
+
env: cleanEnv,
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
cwd: dir,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
status: result.status ?? -1,
|
|
50
|
+
stderr: result.stderr,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runScript(refName: string): ScriptOutput {
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), 'disttag-'));
|
|
56
|
+
const outputFile = join(dir, 'github_output');
|
|
57
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
58
|
+
writeFileSync(outputFile, '');
|
|
59
|
+
const result = spawnSync('bash', [scriptPath], {
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
GITHUB_REF_NAME: refName,
|
|
63
|
+
GITHUB_OUTPUT: outputFile,
|
|
64
|
+
},
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
cwd: dir,
|
|
67
|
+
});
|
|
68
|
+
if (result.status !== 0) {
|
|
69
|
+
throw new Error(`Script failed (status=${String(result.status)}): ${result.stderr}`);
|
|
70
|
+
}
|
|
71
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
72
|
+
return parseOutput(readFileSync(outputFile, 'utf-8'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('compute-disttag.sh', () => {
|
|
76
|
+
it('stable v1.2.3 resolves to latest', () => {
|
|
77
|
+
const out = runScript('v1.2.3');
|
|
78
|
+
expect(out.version).toBe('1.2.3');
|
|
79
|
+
expect(out.disttag).toBe('latest');
|
|
80
|
+
expect(out.is_prerelease).toBe('false');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('v1.2.3-beta.1 resolves to beta dist-tag', () => {
|
|
84
|
+
const out = runScript('v1.2.3-beta.1');
|
|
85
|
+
expect(out.version).toBe('1.2.3-beta.1');
|
|
86
|
+
expect(out.disttag).toBe('beta');
|
|
87
|
+
expect(out.is_prerelease).toBe('true');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('v1.2.3-rc.2 resolves to rc dist-tag', () => {
|
|
91
|
+
const out = runScript('v1.2.3-rc.2');
|
|
92
|
+
expect(out.disttag).toBe('rc');
|
|
93
|
+
expect(out.is_prerelease).toBe('true');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('v2.0.0-alpha.7 resolves to alpha dist-tag', () => {
|
|
97
|
+
const out = runScript('v2.0.0-alpha.7');
|
|
98
|
+
expect(out.disttag).toBe('alpha');
|
|
99
|
+
expect(out.is_prerelease).toBe('true');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('v2.0.0-next.0 resolves to next dist-tag', () => {
|
|
103
|
+
const out = runScript('v2.0.0-next.0');
|
|
104
|
+
expect(out.disttag).toBe('next');
|
|
105
|
+
expect(out.is_prerelease).toBe('true');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('v1.0.0-beta without dot suffix resolves to beta', () => {
|
|
109
|
+
const out = runScript('v1.0.0-beta');
|
|
110
|
+
expect(out.version).toBe('1.0.0-beta');
|
|
111
|
+
expect(out.disttag).toBe('beta');
|
|
112
|
+
expect(out.is_prerelease).toBe('true');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('v1.0.0-beta-2 (valid semver, hyphen in prerelease) resolves to beta-2', () => {
|
|
116
|
+
const out = runScript('v1.0.0-beta-2');
|
|
117
|
+
expect(out.version).toBe('1.0.0-beta-2');
|
|
118
|
+
expect(out.disttag).toBe('beta-2');
|
|
119
|
+
expect(out.is_prerelease).toBe('true');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('v1.2.3-BETA.1 lowercases the disttag to beta', () => {
|
|
123
|
+
const out = runScript('v1.2.3-BETA.1');
|
|
124
|
+
expect(out.version).toBe('1.2.3-BETA.1');
|
|
125
|
+
expect(out.disttag).toBe('beta');
|
|
126
|
+
expect(out.is_prerelease).toBe('true');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('compute-disttag.sh — error handling', () => {
|
|
131
|
+
it('exits non-zero when GITHUB_REF_NAME is unset', () => {
|
|
132
|
+
const result = runScriptRaw({ GITHUB_OUTPUT: join(tmpdir(), 'unused') });
|
|
133
|
+
expect(result.status).not.toBe(0);
|
|
134
|
+
expect(result.stderr).toContain('GITHUB_REF_NAME');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('exits non-zero when GITHUB_REF_NAME is empty', () => {
|
|
138
|
+
const result = runScriptRaw({
|
|
139
|
+
GITHUB_REF_NAME: '',
|
|
140
|
+
GITHUB_OUTPUT: join(tmpdir(), 'unused'),
|
|
141
|
+
});
|
|
142
|
+
expect(result.status).not.toBe(0);
|
|
143
|
+
expect(result.stderr).toContain('GITHUB_REF_NAME');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('exits non-zero when GITHUB_OUTPUT is unset', () => {
|
|
147
|
+
const result = runScriptRaw({ GITHUB_REF_NAME: 'v1.2.3' });
|
|
148
|
+
expect(result.status).not.toBe(0);
|
|
149
|
+
expect(result.stderr).toContain('GITHUB_OUTPUT');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('rejects non-semver tag vA.B.C', () => {
|
|
153
|
+
const result = runScriptRaw({
|
|
154
|
+
GITHUB_REF_NAME: 'vA.B.C',
|
|
155
|
+
GITHUB_OUTPUT: join(tmpdir(), 'unused'),
|
|
156
|
+
});
|
|
157
|
+
expect(result.status).not.toBe(0);
|
|
158
|
+
expect(result.stderr).toContain('not a valid semver tag');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects non-semver tag v1.2', () => {
|
|
162
|
+
const result = runScriptRaw({
|
|
163
|
+
GITHUB_REF_NAME: 'v1.2',
|
|
164
|
+
GITHUB_OUTPUT: join(tmpdir(), 'unused'),
|
|
165
|
+
});
|
|
166
|
+
expect(result.status).not.toBe(0);
|
|
167
|
+
expect(result.stderr).toContain('not a valid semver tag');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('rejects build-metadata tag v1.2.3+build.1', () => {
|
|
171
|
+
const result = runScriptRaw({
|
|
172
|
+
GITHUB_REF_NAME: 'v1.2.3+build.1',
|
|
173
|
+
GITHUB_OUTPUT: join(tmpdir(), 'unused'),
|
|
174
|
+
});
|
|
175
|
+
expect(result.status).not.toBe(0);
|
|
176
|
+
expect(result.stderr).toContain('not a valid semver tag');
|
|
177
|
+
});
|
|
178
|
+
});
|