@shipfox/annotations 2.0.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -0
- package/dist/core/entities/annotation.d.ts +21 -0
- package/dist/core/entities/annotation.d.ts.map +1 -0
- package/dist/core/entities/annotation.js +3 -0
- package/dist/core/entities/annotation.js.map +1 -0
- package/dist/core/entities/index.d.ts +2 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +3 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/errors.d.ts +13 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +20 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/write-annotations.d.ts +25 -0
- package/dist/core/write-annotations.d.ts.map +1 -0
- package/dist/core/write-annotations.js +105 -0
- package/dist/core/write-annotations.js.map +1 -0
- package/dist/db/annotations.d.ts +63 -0
- package/dist/db/annotations.d.ts.map +1 -0
- package/dist/db/annotations.js +96 -0
- package/dist/db/annotations.js.map +1 -0
- package/dist/db/db.d.ts +304 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +6 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema/annotations.d.ts +302 -0
- package/dist/db/schema/annotations.d.ts.map +1 -0
- package/dist/db/schema/annotations.js +67 -0
- package/dist/db/schema/annotations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/annotation.d.ts +4 -0
- package/dist/presentation/dto/annotation.d.ts.map +1 -0
- package/dist/presentation/dto/annotation.js +15 -0
- package/dist/presentation/dto/annotation.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +21 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/read-annotations.d.ts +2 -0
- package/dist/presentation/routes/read-annotations.d.ts.map +1 -0
- package/dist/presentation/routes/read-annotations.js +49 -0
- package/dist/presentation/routes/read-annotations.js.map +1 -0
- package/dist/presentation/routes/write-annotations.d.ts +2 -0
- package/dist/presentation/routes/write-annotations.d.ts.map +1 -0
- package/dist/presentation/routes/write-annotations.js +73 -0
- package/dist/presentation/routes/write-annotations.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +32 -0
- package/drizzle/meta/0000_snapshot.json +247 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +69 -0
- package/src/config.test.ts +45 -0
- package/src/config.ts +26 -0
- package/src/core/entities/annotation.ts +21 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/errors.ts +20 -0
- package/src/core/write-annotations.test.ts +261 -0
- package/src/core/write-annotations.ts +142 -0
- package/src/db/annotations.test.ts +261 -0
- package/src/db/annotations.ts +180 -0
- package/src/db/db.ts +19 -0
- package/src/db/index.ts +7 -0
- package/src/db/schema/annotations.test.ts +50 -0
- package/src/db/schema/annotations.ts +96 -0
- package/src/db/schema/common.ts +3 -0
- package/src/index.ts +17 -0
- package/src/presentation/dto/annotation.test.ts +42 -0
- package/src/presentation/dto/annotation.ts +16 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/routes/index.ts +17 -0
- package/src/presentation/routes/read-annotations.test.ts +279 -0
- package/src/presentation/routes/read-annotations.ts +50 -0
- package/src/presentation/routes/write-annotations.test.ts +188 -0
- package/src/presentation/routes/write-annotations.ts +76 -0
- package/test/env.ts +5 -0
- package/test/factories/annotation.ts +57 -0
- package/test/fixtures/lease-token.ts +43 -0
- package/test/globalSetup.ts +20 -0
- package/test/index.ts +2 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {AUTH_USER, buildUserContext, setUserContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
|
|
3
|
+
import type {FastifyRequest} from 'fastify';
|
|
4
|
+
import {annotationFactory} from '#test/index.js';
|
|
5
|
+
import {readAnnotationsRoute} from './read-annotations.js';
|
|
6
|
+
|
|
7
|
+
const fakeUserAuth: AuthMethod = {
|
|
8
|
+
name: AUTH_USER,
|
|
9
|
+
authenticate: (request: FastifyRequest) => {
|
|
10
|
+
if (request.headers.authorization !== 'Bearer user') {
|
|
11
|
+
throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const header = request.headers['x-test-workspaces'];
|
|
15
|
+
const rawWorkspaceIds = Array.isArray(header) ? header.join(',') : (header ?? '');
|
|
16
|
+
const memberships = rawWorkspaceIds
|
|
17
|
+
.split(',')
|
|
18
|
+
.filter((workspaceId) => workspaceId.length > 0)
|
|
19
|
+
.map((workspaceId) => ({workspaceId, role: 'admin' as const}));
|
|
20
|
+
|
|
21
|
+
setUserContext(
|
|
22
|
+
request,
|
|
23
|
+
buildUserContext({
|
|
24
|
+
userId: 'user-1',
|
|
25
|
+
email: 'user@example.com',
|
|
26
|
+
memberships,
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('GET /annotations', () => {
|
|
34
|
+
let app: Awaited<ReturnType<typeof createApp>>;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
app = await createApp({
|
|
38
|
+
auth: [fakeUserAuth],
|
|
39
|
+
routes: [{prefix: '/annotations', auth: AUTH_USER, routes: [readAnnotationsRoute]}],
|
|
40
|
+
swagger: false,
|
|
41
|
+
});
|
|
42
|
+
await app.ready();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await closeApp();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function readUrl(params: {
|
|
50
|
+
workflowRunId: string;
|
|
51
|
+
attempt: number;
|
|
52
|
+
jobExecutionId?: string | undefined;
|
|
53
|
+
cursor?: string | undefined;
|
|
54
|
+
limit?: number | undefined;
|
|
55
|
+
}) {
|
|
56
|
+
const search = new URLSearchParams({
|
|
57
|
+
workflow_run_id: params.workflowRunId,
|
|
58
|
+
attempt: String(params.attempt),
|
|
59
|
+
});
|
|
60
|
+
if (params.jobExecutionId) search.set('job_execution_id', params.jobExecutionId);
|
|
61
|
+
if (params.cursor) search.set('cursor', params.cursor);
|
|
62
|
+
if (params.limit !== undefined) search.set('limit', String(params.limit));
|
|
63
|
+
return `/annotations?${search.toString()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it('rejects a request without a session token', async () => {
|
|
67
|
+
const res = await app.inject({
|
|
68
|
+
method: 'GET',
|
|
69
|
+
url: readUrl({workflowRunId: crypto.randomUUID(), attempt: 1}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(res.statusCode).toBe(401);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns annotations converted to DTOs for a visible run attempt', async () => {
|
|
76
|
+
const workspaceId = crypto.randomUUID();
|
|
77
|
+
const workflowRunId = crypto.randomUUID();
|
|
78
|
+
const jobId = crypto.randomUUID();
|
|
79
|
+
const jobExecutionId = crypto.randomUUID();
|
|
80
|
+
const originStepId = crypto.randomUUID();
|
|
81
|
+
const annotation = await annotationFactory.create({
|
|
82
|
+
workspaceId,
|
|
83
|
+
workflowRunId,
|
|
84
|
+
workflowRunAttempt: 2,
|
|
85
|
+
jobId,
|
|
86
|
+
jobExecutionId,
|
|
87
|
+
originStepId,
|
|
88
|
+
originStepAttempt: 3,
|
|
89
|
+
context: 'deploy',
|
|
90
|
+
style: 'success',
|
|
91
|
+
sequence: 4,
|
|
92
|
+
body: 'Deployed **v42**',
|
|
93
|
+
bodyBytes: Buffer.byteLength('Deployed **v42**'),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const res = await app.inject({
|
|
97
|
+
method: 'GET',
|
|
98
|
+
url: readUrl({workflowRunId, attempt: 2}),
|
|
99
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': workspaceId},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(res.statusCode).toBe(200);
|
|
103
|
+
expect(res.json()).toEqual({
|
|
104
|
+
annotations: [
|
|
105
|
+
{
|
|
106
|
+
id: annotation.id,
|
|
107
|
+
job_id: jobId,
|
|
108
|
+
job_execution_id: jobExecutionId,
|
|
109
|
+
origin_step_id: originStepId,
|
|
110
|
+
origin_step_attempt: 3,
|
|
111
|
+
context: 'deploy',
|
|
112
|
+
style: 'success',
|
|
113
|
+
sequence: 4,
|
|
114
|
+
body: 'Deployed **v42**',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
has_more: false,
|
|
118
|
+
next_cursor: null,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns an empty list when no rows match the run attempt', async () => {
|
|
123
|
+
const res = await app.inject({
|
|
124
|
+
method: 'GET',
|
|
125
|
+
url: readUrl({workflowRunId: crypto.randomUUID(), attempt: 1}),
|
|
126
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(res.statusCode).toBe(200);
|
|
130
|
+
expect(res.json()).toEqual({annotations: [], has_more: false, next_cursor: null});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns an empty list for annotations outside the user workspaces', async () => {
|
|
134
|
+
const workflowRunId = crypto.randomUUID();
|
|
135
|
+
await annotationFactory.create({workflowRunId, workspaceId: crypto.randomUUID()});
|
|
136
|
+
|
|
137
|
+
const res = await app.inject({
|
|
138
|
+
method: 'GET',
|
|
139
|
+
url: readUrl({workflowRunId, attempt: 1}),
|
|
140
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(res.statusCode).toBe(200);
|
|
144
|
+
expect(res.json()).toEqual({annotations: [], has_more: false, next_cursor: null});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns an empty list when the job execution filter has no matches', async () => {
|
|
148
|
+
const workspaceId = crypto.randomUUID();
|
|
149
|
+
const workflowRunId = crypto.randomUUID();
|
|
150
|
+
await annotationFactory.create({workspaceId, workflowRunId});
|
|
151
|
+
|
|
152
|
+
const res = await app.inject({
|
|
153
|
+
method: 'GET',
|
|
154
|
+
url: readUrl({
|
|
155
|
+
workflowRunId,
|
|
156
|
+
attempt: 1,
|
|
157
|
+
jobExecutionId: crypto.randomUUID(),
|
|
158
|
+
}),
|
|
159
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': workspaceId},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(res.statusCode).toBe(200);
|
|
163
|
+
expect(res.json()).toEqual({annotations: [], has_more: false, next_cursor: null});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('limits the returned annotations and returns a continuation cursor', async () => {
|
|
167
|
+
const workspaceId = crypto.randomUUID();
|
|
168
|
+
const workflowRunId = crypto.randomUUID();
|
|
169
|
+
const first = await annotationFactory.create({
|
|
170
|
+
workspaceId,
|
|
171
|
+
workflowRunId,
|
|
172
|
+
jobExecutionId: crypto.randomUUID(),
|
|
173
|
+
context: 'first',
|
|
174
|
+
sequence: 1,
|
|
175
|
+
});
|
|
176
|
+
const second = await annotationFactory.create({
|
|
177
|
+
workspaceId,
|
|
178
|
+
workflowRunId,
|
|
179
|
+
jobExecutionId: crypto.randomUUID(),
|
|
180
|
+
context: 'second',
|
|
181
|
+
sequence: 2,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const firstPage = await app.inject({
|
|
185
|
+
method: 'GET',
|
|
186
|
+
url: readUrl({workflowRunId, attempt: 1, limit: 1}),
|
|
187
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': workspaceId},
|
|
188
|
+
});
|
|
189
|
+
expect(firstPage.statusCode).toBe(200);
|
|
190
|
+
const firstBody = firstPage.json();
|
|
191
|
+
expect(firstBody).toMatchObject({
|
|
192
|
+
annotations: [
|
|
193
|
+
{
|
|
194
|
+
id: first.id,
|
|
195
|
+
context: 'first',
|
|
196
|
+
sequence: 1,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
has_more: true,
|
|
200
|
+
});
|
|
201
|
+
expect(typeof firstBody.next_cursor).toBe('string');
|
|
202
|
+
|
|
203
|
+
const secondPage = await app.inject({
|
|
204
|
+
method: 'GET',
|
|
205
|
+
url: readUrl({
|
|
206
|
+
workflowRunId,
|
|
207
|
+
attempt: 1,
|
|
208
|
+
cursor: firstBody.next_cursor,
|
|
209
|
+
limit: 1,
|
|
210
|
+
}),
|
|
211
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': workspaceId},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(secondPage.statusCode).toBe(200);
|
|
215
|
+
expect(secondPage.json()).toMatchObject({
|
|
216
|
+
annotations: [
|
|
217
|
+
{
|
|
218
|
+
id: second.id,
|
|
219
|
+
context: 'second',
|
|
220
|
+
sequence: 2,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
has_more: false,
|
|
224
|
+
next_cursor: null,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('rejects malformed query values', async () => {
|
|
229
|
+
const res = await app.inject({
|
|
230
|
+
method: 'GET',
|
|
231
|
+
url: '/annotations?workflow_run_id=not-a-uuid&attempt=0',
|
|
232
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(res.statusCode).toBe(400);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects attempts outside the database integer range', async () => {
|
|
239
|
+
const res = await app.inject({
|
|
240
|
+
method: 'GET',
|
|
241
|
+
url: readUrl({workflowRunId: crypto.randomUUID(), attempt: 2_147_483_648}),
|
|
242
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(res.statusCode).toBe(400);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('rejects limits above the server response cap', async () => {
|
|
249
|
+
const res = await app.inject({
|
|
250
|
+
method: 'GET',
|
|
251
|
+
url: readUrl({workflowRunId: crypto.randomUUID(), attempt: 1, limit: 501}),
|
|
252
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(res.statusCode).toBe(400);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('rejects malformed continuation cursors', async () => {
|
|
259
|
+
const res = await app.inject({
|
|
260
|
+
method: 'GET',
|
|
261
|
+
url: readUrl({workflowRunId: crypto.randomUUID(), attempt: 1, cursor: 'not-a-cursor'}),
|
|
262
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(res.statusCode).toBe(400);
|
|
266
|
+
expect(res.json().code).toBe('invalid-cursor');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('rejects empty continuation cursors', async () => {
|
|
270
|
+
const res = await app.inject({
|
|
271
|
+
method: 'GET',
|
|
272
|
+
url: `${readUrl({workflowRunId: crypto.randomUUID(), attempt: 1})}&cursor=`,
|
|
273
|
+
headers: {authorization: 'Bearer user', 'x-test-workspaces': crypto.randomUUID()},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(res.statusCode).toBe(400);
|
|
277
|
+
expect(res.json().code).toBe('invalid-cursor');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {readAnnotationsQuerySchema, readAnnotationsResponseSchema} from '@shipfox/annotations-dto';
|
|
2
|
+
import {requireUserContext} from '@shipfox/api-auth-context';
|
|
3
|
+
import {decodeNumberIdCursor, encodeNumberIdCursor} from '@shipfox/node-drizzle';
|
|
4
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
5
|
+
import {listAnnotationsForRunAttempt} from '#db/index.js';
|
|
6
|
+
import {toAnnotationDto} from '#presentation/dto/index.js';
|
|
7
|
+
|
|
8
|
+
export const readAnnotationsRoute = defineRoute({
|
|
9
|
+
method: 'GET',
|
|
10
|
+
path: '/',
|
|
11
|
+
description: 'Read annotations for a workflow run attempt.',
|
|
12
|
+
schema: {
|
|
13
|
+
querystring: readAnnotationsQuerySchema,
|
|
14
|
+
response: {
|
|
15
|
+
200: readAnnotationsResponseSchema,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
handler: async (request) => {
|
|
19
|
+
const user = requireUserContext(request);
|
|
20
|
+
const {
|
|
21
|
+
workflow_run_id: workflowRunId,
|
|
22
|
+
attempt,
|
|
23
|
+
job_execution_id: jobExecutionId,
|
|
24
|
+
cursor,
|
|
25
|
+
limit,
|
|
26
|
+
} = request.query;
|
|
27
|
+
const decodedCursor = decodeNumberIdCursor(cursor);
|
|
28
|
+
if (cursor !== undefined && !decodedCursor) {
|
|
29
|
+
throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const workspaceIds = user.memberships.map((membership) => membership.workspaceId);
|
|
33
|
+
const result = await listAnnotationsForRunAttempt({
|
|
34
|
+
workflowRunId,
|
|
35
|
+
workflowRunAttempt: attempt,
|
|
36
|
+
workspaceIds,
|
|
37
|
+
jobExecutionId,
|
|
38
|
+
after: decodedCursor ? {sequence: decodedCursor.value, id: decodedCursor.id} : undefined,
|
|
39
|
+
limit,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
annotations: result.annotations.map(toAnnotationDto),
|
|
44
|
+
has_more: result.hasMore,
|
|
45
|
+
next_cursor: result.nextCursor
|
|
46
|
+
? encodeNumberIdCursor({value: result.nextCursor.sequence, id: result.nextCursor.id})
|
|
47
|
+
: null,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {createLeaseTokenAuthMethod} from '@shipfox/api-auth';
|
|
2
|
+
import {AUTH_LEASED_JOB} from '@shipfox/api-auth-context';
|
|
3
|
+
import {closeApp, createApp, type FastifyInstance} from '@shipfox/node-fastify';
|
|
4
|
+
import {eq} from 'drizzle-orm';
|
|
5
|
+
import {db} from '#db/db.js';
|
|
6
|
+
import {annotations} from '#db/schema/annotations.js';
|
|
7
|
+
import {mintLeaseToken} from '#test/index.js';
|
|
8
|
+
import {writeAnnotationsRoute} from './write-annotations.js';
|
|
9
|
+
|
|
10
|
+
function annotationsUrl(): string {
|
|
11
|
+
return '/runs/jobs/current/annotations';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mintTestLeaseToken(params: {
|
|
15
|
+
jobId?: string;
|
|
16
|
+
jobExecutionId?: string;
|
|
17
|
+
stepId?: string;
|
|
18
|
+
attempt?: number;
|
|
19
|
+
}): Promise<string> {
|
|
20
|
+
return mintLeaseToken({
|
|
21
|
+
jobId: params.jobId ?? crypto.randomUUID(),
|
|
22
|
+
jobExecutionId: params.jobExecutionId ?? crypto.randomUUID(),
|
|
23
|
+
...(params.stepId
|
|
24
|
+
? {currentStepId: params.stepId, currentStepAttempt: params.attempt ?? 1}
|
|
25
|
+
: {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('POST /runs/jobs/current/annotations', () => {
|
|
30
|
+
let app: FastifyInstance;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
app = await createApp({
|
|
34
|
+
auth: [createLeaseTokenAuthMethod()],
|
|
35
|
+
routes: [
|
|
36
|
+
{prefix: '/runs/jobs/current', auth: AUTH_LEASED_JOB, routes: [writeAnnotationsRoute]},
|
|
37
|
+
],
|
|
38
|
+
swagger: false,
|
|
39
|
+
});
|
|
40
|
+
await app.ready();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await closeApp();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a request without a lease token', async () => {
|
|
48
|
+
const res = await app.inject({
|
|
49
|
+
method: 'POST',
|
|
50
|
+
url: annotationsUrl(),
|
|
51
|
+
payload: {step_id: crypto.randomUUID(), attempt: 1, annotations: []},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(res.statusCode).toBe(401);
|
|
55
|
+
expect(res.json().code).toBe('unauthorized');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts a scoped lease and writes annotations from lease identity', async () => {
|
|
59
|
+
const stepId = crypto.randomUUID();
|
|
60
|
+
const jobId = crypto.randomUUID();
|
|
61
|
+
const jobExecutionId = crypto.randomUUID();
|
|
62
|
+
const token = await mintTestLeaseToken({jobId, jobExecutionId, stepId});
|
|
63
|
+
|
|
64
|
+
const res = await app.inject({
|
|
65
|
+
method: 'POST',
|
|
66
|
+
url: annotationsUrl(),
|
|
67
|
+
headers: {authorization: `Bearer ${token}`},
|
|
68
|
+
payload: {
|
|
69
|
+
step_id: stepId,
|
|
70
|
+
attempt: 1,
|
|
71
|
+
annotations: [{context: 'deploy', style: 'success', op: 'replace', body: 'done'}],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const rows = await db()
|
|
76
|
+
.select()
|
|
77
|
+
.from(annotations)
|
|
78
|
+
.where(eq(annotations.jobExecutionId, jobExecutionId));
|
|
79
|
+
expect(res.statusCode).toBe(200);
|
|
80
|
+
expect(res.json()).toMatchObject({
|
|
81
|
+
annotations: [{context: 'deploy', id: rows[0]?.id}],
|
|
82
|
+
accounting: {annotation_count: 1, total_body_bytes: 4},
|
|
83
|
+
});
|
|
84
|
+
expect(rows[0]).toMatchObject({
|
|
85
|
+
jobId,
|
|
86
|
+
originStepId: stepId,
|
|
87
|
+
originStepAttempt: 1,
|
|
88
|
+
context: 'deploy',
|
|
89
|
+
body: 'done',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects writes for a step outside the leased execution', async () => {
|
|
94
|
+
const token = await mintTestLeaseToken({stepId: crypto.randomUUID()});
|
|
95
|
+
|
|
96
|
+
const res = await app.inject({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
url: annotationsUrl(),
|
|
99
|
+
headers: {authorization: `Bearer ${token}`},
|
|
100
|
+
payload: {
|
|
101
|
+
step_id: crypto.randomUUID(),
|
|
102
|
+
attempt: 1,
|
|
103
|
+
annotations: [{context: 'deploy', body: 'wrong step'}],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(res.statusCode).toBe(404);
|
|
108
|
+
expect(res.json().code).toBe('step-not-found');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects writes for a different attempt than the leased step scope', async () => {
|
|
112
|
+
const stepId = crypto.randomUUID();
|
|
113
|
+
const token = await mintTestLeaseToken({stepId, attempt: 1});
|
|
114
|
+
|
|
115
|
+
const res = await app.inject({
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: annotationsUrl(),
|
|
118
|
+
headers: {authorization: `Bearer ${token}`},
|
|
119
|
+
payload: {
|
|
120
|
+
step_id: stepId,
|
|
121
|
+
attempt: 2,
|
|
122
|
+
annotations: [{context: 'deploy', body: 'wrong attempt'}],
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(res.statusCode).toBe(404);
|
|
127
|
+
expect(res.json().code).toBe('step-not-found');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects writes with a job-scoped lease token', async () => {
|
|
131
|
+
const token = await mintLeaseToken({
|
|
132
|
+
jobId: crypto.randomUUID(),
|
|
133
|
+
jobExecutionId: crypto.randomUUID(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const res = await app.inject({
|
|
137
|
+
method: 'POST',
|
|
138
|
+
url: annotationsUrl(),
|
|
139
|
+
headers: {authorization: `Bearer ${token}`},
|
|
140
|
+
payload: {
|
|
141
|
+
step_id: crypto.randomUUID(),
|
|
142
|
+
attempt: 1,
|
|
143
|
+
annotations: [{context: 'deploy', body: 'job scoped'}],
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(res.statusCode).toBe(404);
|
|
148
|
+
expect(res.json().code).toBe('step-not-found');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('accepts an empty operation list and returns current accounting', async () => {
|
|
152
|
+
const stepId = crypto.randomUUID();
|
|
153
|
+
const token = await mintTestLeaseToken({stepId});
|
|
154
|
+
|
|
155
|
+
const res = await app.inject({
|
|
156
|
+
method: 'POST',
|
|
157
|
+
url: annotationsUrl(),
|
|
158
|
+
headers: {authorization: `Bearer ${token}`},
|
|
159
|
+
payload: {step_id: stepId, attempt: 1, annotations: []},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(res.statusCode).toBe(200);
|
|
163
|
+
expect(res.json()).toEqual({
|
|
164
|
+
annotations: [],
|
|
165
|
+
accounting: {annotation_count: 0, total_body_bytes: 0},
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('maps annotation count budget failures to a specific client code', async () => {
|
|
170
|
+
const stepId = crypto.randomUUID();
|
|
171
|
+
const token = await mintTestLeaseToken({stepId});
|
|
172
|
+
const operations = Array.from({length: 51}, (_, index) => ({
|
|
173
|
+
context: `context-${index}`,
|
|
174
|
+
op: 'replace',
|
|
175
|
+
body: 'x',
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
const res = await app.inject({
|
|
179
|
+
method: 'POST',
|
|
180
|
+
url: annotationsUrl(),
|
|
181
|
+
headers: {authorization: `Bearer ${token}`},
|
|
182
|
+
payload: {step_id: stepId, attempt: 1, annotations: operations},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(res.statusCode).toBe(413);
|
|
186
|
+
expect(res.json().code).toBe('annotation-count-limit-exceeded');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
leasedWriteAnnotationsBodySchema,
|
|
3
|
+
leasedWriteAnnotationsResponseSchema,
|
|
4
|
+
} from '@shipfox/annotations-dto';
|
|
5
|
+
import {requireLeasedJobContext} from '@shipfox/api-auth-context';
|
|
6
|
+
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
7
|
+
import {
|
|
8
|
+
AnnotationBodyTooLargeError,
|
|
9
|
+
AnnotationCountLimitExceededError,
|
|
10
|
+
AnnotationTotalBytesLimitExceededError,
|
|
11
|
+
} from '#core/errors.js';
|
|
12
|
+
import {writeAnnotations} from '#core/write-annotations.js';
|
|
13
|
+
|
|
14
|
+
export const writeAnnotationsRoute = defineRoute({
|
|
15
|
+
method: 'POST',
|
|
16
|
+
path: '/annotations',
|
|
17
|
+
description: 'Creates, updates, or removes annotations for the current leased job execution.',
|
|
18
|
+
schema: {
|
|
19
|
+
body: leasedWriteAnnotationsBodySchema,
|
|
20
|
+
response: {
|
|
21
|
+
200: leasedWriteAnnotationsResponseSchema,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
errorHandler: (error) => {
|
|
25
|
+
if (error instanceof AnnotationBodyTooLargeError) {
|
|
26
|
+
throw new ClientError(error.message, 'annotation-body-too-large', {
|
|
27
|
+
status: 413,
|
|
28
|
+
details: {max_body_bytes: error.maxBytes},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof AnnotationCountLimitExceededError) {
|
|
32
|
+
throw new ClientError(error.message, 'annotation-count-limit-exceeded', {
|
|
33
|
+
status: 413,
|
|
34
|
+
details: {max_annotations: error.maxAnnotations},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (error instanceof AnnotationTotalBytesLimitExceededError) {
|
|
38
|
+
throw new ClientError(error.message, 'annotation-total-bytes-limit-exceeded', {
|
|
39
|
+
status: 413,
|
|
40
|
+
details: {max_total_body_bytes: error.maxBytes},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
},
|
|
45
|
+
handler: async (request) => {
|
|
46
|
+
const leasedJob = requireLeasedJobContext(request);
|
|
47
|
+
const {step_id: stepId, attempt, annotations} = request.body;
|
|
48
|
+
|
|
49
|
+
if (leasedJob.currentStepId !== stepId || leasedJob.currentStepAttempt !== attempt) {
|
|
50
|
+
throw new ClientError('Step not found for leased job execution', 'step-not-found', {
|
|
51
|
+
status: 404,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = await writeAnnotations({
|
|
56
|
+
workspaceId: leasedJob.workspaceId,
|
|
57
|
+
projectId: leasedJob.projectId,
|
|
58
|
+
workflowRunId: leasedJob.workflowRunId,
|
|
59
|
+
workflowRunAttempt: leasedJob.workflowRunAttempt ?? 1,
|
|
60
|
+
workflowRunAttemptId: leasedJob.workflowRunAttemptId,
|
|
61
|
+
jobId: leasedJob.jobId,
|
|
62
|
+
jobExecutionId: leasedJob.jobExecutionId,
|
|
63
|
+
originStepId: stepId,
|
|
64
|
+
originStepAttempt: attempt,
|
|
65
|
+
operations: annotations,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
annotations: result.annotations,
|
|
70
|
+
accounting: {
|
|
71
|
+
annotation_count: result.accounting.annotationCount,
|
|
72
|
+
total_body_bytes: result.accounting.totalBodyBytes,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
package/test/env.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
process.env.DATABASE_URL ??= 'postgres://shipfox:shipfox@127.0.0.1:5432/api_test';
|
|
2
|
+
process.env.AUTH_JWT_SECRET = 'test-jwt-secret';
|
|
3
|
+
process.env.AUTH_JOB_LEASE_TOKEN_SECRET = 'test-lease-secret';
|
|
4
|
+
process.env.AUTH_RUNNER_SESSION_TOKEN_SECRET = 'test-runner-session-secret';
|
|
5
|
+
process.env.TZ = 'UTC';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {Factory} from 'fishery';
|
|
2
|
+
import type {Annotation} from '#core/entities/annotation.js';
|
|
3
|
+
import {db} from '#db/db.js';
|
|
4
|
+
import {annotations} from '#db/schema/annotations.js';
|
|
5
|
+
|
|
6
|
+
export const annotationFactory = Factory.define<Annotation>(({onCreate}) => {
|
|
7
|
+
onCreate(async (annotation) => {
|
|
8
|
+
const [row] = await db()
|
|
9
|
+
.insert(annotations)
|
|
10
|
+
.values({
|
|
11
|
+
id: annotation.id,
|
|
12
|
+
workspaceId: annotation.workspaceId,
|
|
13
|
+
projectId: annotation.projectId,
|
|
14
|
+
workflowRunId: annotation.workflowRunId,
|
|
15
|
+
workflowRunAttempt: annotation.workflowRunAttempt,
|
|
16
|
+
workflowRunAttemptId: annotation.workflowRunAttemptId,
|
|
17
|
+
jobId: annotation.jobId,
|
|
18
|
+
jobExecutionId: annotation.jobExecutionId,
|
|
19
|
+
originStepId: annotation.originStepId,
|
|
20
|
+
originStepAttempt: annotation.originStepAttempt,
|
|
21
|
+
context: annotation.context,
|
|
22
|
+
style: annotation.style,
|
|
23
|
+
body: annotation.body,
|
|
24
|
+
bodyBytes: annotation.bodyBytes,
|
|
25
|
+
sequence: annotation.sequence,
|
|
26
|
+
createdAt: annotation.createdAt,
|
|
27
|
+
updatedAt: annotation.updatedAt,
|
|
28
|
+
})
|
|
29
|
+
.returning();
|
|
30
|
+
|
|
31
|
+
if (!row) throw new Error('annotationFactory: insert returned no row');
|
|
32
|
+
return annotation;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const body = '### Summary\nAnnotation body';
|
|
36
|
+
const now = new Date();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: crypto.randomUUID(),
|
|
40
|
+
workspaceId: crypto.randomUUID(),
|
|
41
|
+
projectId: crypto.randomUUID(),
|
|
42
|
+
workflowRunId: crypto.randomUUID(),
|
|
43
|
+
workflowRunAttempt: 1,
|
|
44
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
45
|
+
jobId: crypto.randomUUID(),
|
|
46
|
+
jobExecutionId: crypto.randomUUID(),
|
|
47
|
+
originStepId: crypto.randomUUID(),
|
|
48
|
+
originStepAttempt: 1,
|
|
49
|
+
context: 'default',
|
|
50
|
+
style: 'default',
|
|
51
|
+
body,
|
|
52
|
+
bodyBytes: Buffer.byteLength(body),
|
|
53
|
+
sequence: 1,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {signHs256} from '@shipfox/node-jwt';
|
|
3
|
+
|
|
4
|
+
const SECRET = process.env.AUTH_JOB_LEASE_TOKEN_SECRET ?? 'test-lease-secret';
|
|
5
|
+
|
|
6
|
+
export interface MintLeaseTokenParams {
|
|
7
|
+
jobId: string;
|
|
8
|
+
jobExecutionId: string;
|
|
9
|
+
workspaceId?: string;
|
|
10
|
+
projectId?: string;
|
|
11
|
+
workflowRunId?: string;
|
|
12
|
+
workflowRunAttempt?: number;
|
|
13
|
+
workflowRunAttemptId?: string;
|
|
14
|
+
currentStepId?: string;
|
|
15
|
+
currentStepAttempt?: number;
|
|
16
|
+
secret?: string;
|
|
17
|
+
expiresIn?: string;
|
|
18
|
+
audience?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function mintLeaseToken(params: MintLeaseTokenParams): Promise<string> {
|
|
22
|
+
return signHs256({
|
|
23
|
+
payload: {
|
|
24
|
+
jobId: params.jobId,
|
|
25
|
+
jobExecutionId: params.jobExecutionId,
|
|
26
|
+
workflowRunId: params.workflowRunId ?? crypto.randomUUID(),
|
|
27
|
+
workflowRunAttempt: params.workflowRunAttempt ?? 1,
|
|
28
|
+
workflowRunAttemptId: params.workflowRunAttemptId ?? crypto.randomUUID(),
|
|
29
|
+
projectId: params.projectId ?? crypto.randomUUID(),
|
|
30
|
+
workspaceId: params.workspaceId ?? crypto.randomUUID(),
|
|
31
|
+
runnerSessionId: crypto.randomUUID(),
|
|
32
|
+
...(params.currentStepId && params.currentStepAttempt !== undefined
|
|
33
|
+
? {
|
|
34
|
+
currentStepId: params.currentStepId,
|
|
35
|
+
currentStepAttempt: params.currentStepAttempt,
|
|
36
|
+
}
|
|
37
|
+
: {}),
|
|
38
|
+
},
|
|
39
|
+
secret: params.secret ?? SECRET,
|
|
40
|
+
expiresIn: params.expiresIn ?? '90m',
|
|
41
|
+
audience: params.audience ?? JOB_LEASE_TOKEN_AUDIENCE,
|
|
42
|
+
});
|
|
43
|
+
}
|