Skip to content

Commit 85b3734

Browse files
committed
Merge remote-tracking branch 'origin/fix/w6-risk' into integrate/v2-w5
2 parents 1fac7df + 922ac91 commit 85b3734

12 files changed

Lines changed: 291 additions & 15 deletions

File tree

apps/sim/app/api/v2/logs/route.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('@/lib/logs/application/list-public-logs', () => ({
2424
listPublicLogs: { operation: { id: 'logs.list' }, execute: mocks.execute },
2525
}))
2626

27+
import { UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
2728
import { OrchestrationError } from '@/lib/core/orchestration/types'
2829
import { cursorFilterScope, encodeScopedCursor } from '@/app/api/v2/lib/response'
2930
import { GET } from '@/app/api/v2/logs/route'
@@ -216,7 +217,14 @@ describe('GET /api/v2/logs', () => {
216217
expect(mocks.execute).not.toHaveBeenCalled()
217218
})
218219

219-
/** Neither param exists on this operation, so naming them sends the caller nowhere. */
220+
/**
221+
* An undecodable token says nothing about which param changed, and this
222+
* operation declares neither `sortBy` nor `sortOrder` under a `.strict()`
223+
* query schema — so the sort-mismatch message would answer one 400 with
224+
* advice that earns a second. The message is asserted exactly rather than by
225+
* absence: "does not say sortBy" is satisfied by almost any wording, including
226+
* one that tells the caller nothing at all.
227+
*/
220228
it('names the params a rejected cursor is actually bound to', async () => {
221229
const response = await GET(
222230
new NextRequest(
@@ -225,6 +233,8 @@ describe('GET /api/v2/logs', () => {
225233
)
226234

227235
const body = await response.json()
236+
expect(body.error.message).toBe(UNREADABLE_CURSOR_MESSAGE)
237+
expect(body.error.message).toContain('Restart pagination without a cursor')
228238
expect(body.error.message).not.toContain('sortBy')
229239
expect(body.error.message).not.toContain('sortOrder')
230240
})

apps/sim/app/api/v2/skills/route.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock('@/lib/skills/application/use-cases', () => ({
5050
createSkillUseCase: { operation: { id: 'skills.create' }, execute: mocks.create },
5151
}))
5252

53+
import { PrincipalKindAuthorizationError } from '@/lib/core/application'
5354
import { cursorFilterScope, cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response'
5455
import { GET, POST } from '@/app/api/v2/skills/route'
5556

@@ -311,6 +312,33 @@ describe('/api/v2/skills', () => {
311312
)
312313
})
313314

315+
/**
316+
* The workspace-key create used to be the case this file pinned analytics
317+
* against: it succeeded, and the assertion was that no `skill_created` event
318+
* was attributed to a principal with no human subject. `skills.create` now
319+
* denies the key outright, so what needs pinning here is the surface's half of
320+
* that — the refusal reaches the caller as the operation's own 403, and a
321+
* create that never happened emits nothing.
322+
*/
323+
it('refuses a workspace-key create and records no analytics for it', async () => {
324+
mocks.create.mockRejectedValueOnce(
325+
new PrincipalKindAuthorizationError('workspace_api_key', 'skills.create')
326+
)
327+
328+
const response = await POST(
329+
request('POST', '/api/v2/skills', {
330+
workspaceId: WORKSPACE_ID,
331+
name: skill.name,
332+
description: skill.description,
333+
content: skill.content,
334+
})
335+
)
336+
337+
expect(response.status).toBe(403)
338+
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ principal: PRINCIPAL }))
339+
expect(mocks.capture).not.toHaveBeenCalled()
340+
})
341+
314342
it('authenticates before parsing skill input', async () => {
315343
mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError())
316344

apps/sim/lib/api/server/routes/v2-json-route.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
defineV2JsonRoute,
3636
type V2ErrorPolicy,
3737
v2ApiKeyAuth,
38+
v2HeadAuthorizationResponse,
3839
v2OrchestrationErrorPolicy,
3940
v2RateLimits,
4041
} from '@/lib/api/server/routes/v2-json-route'
@@ -623,4 +624,23 @@ describe('defineV2JsonRoute HEAD on a route that is not head-safe', () => {
623624
it('refuses at definition time to build the route when the use case cannot authorize', () => {
624625
expect(() => createHeadHandler({ omitAuthorize: true })).toThrow(/authorize/)
625626
})
627+
628+
/**
629+
* The definition-time guard is what a route hits, and it covers both builders
630+
* that answer a `HEAD` this way. This pins the responder's own behaviour if it
631+
* is ever reached another way: a missing authorization phase has to fail,
632+
* because skipping it hands back the bodiless 200 for a resource nothing
633+
* authorized — the leak the guard exists to prevent, restored.
634+
*/
635+
it('refuses to answer 200 when the authorization phase is missing', async () => {
636+
await expect(
637+
v2HeadAuthorizationResponse({
638+
useCase: { authorize: undefined },
639+
principal,
640+
input: { widgetId: 'widget-1', workspaceId: 'workspace-1' },
641+
request: headRequest(),
642+
errorPolicy: v2OrchestrationErrorPolicy,
643+
})
644+
).rejects.toThrow(/authorize/)
645+
})
626646
})

apps/sim/lib/api/server/routes/v2-json-route.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ export function requireHeadAuthorizableUseCase(
195195
* produced — 400, 401, 403, 404, 429 — and only an authorized caller reaches the
196196
* 200. What a `HEAD` never reaches is the use case's business phase, so the
197197
* outbound connection, the row write, and the audit event stay unfired.
198+
*
199+
* A use case with no `authorize` is refused here rather than skipped. Both
200+
* builders that call this already refuse such a route at module load through
201+
* {@link requireHeadAuthorizableUseCase}, and they are its only callers, so the
202+
* refusal is unreachable through them. It is not written as a comment because
203+
* the alternative — an optional call — degrades a missing phase into exactly the
204+
* bodiless 200 this function exists to stop, and it does so silently. Failing
205+
* closed makes an authorization that actually ran the only route to that 200.
198206
*/
199207
export async function v2HeadAuthorizationResponse(args: {
200208
useCase: Pick<OperationUseCase<ApplicationOperation, unknown, unknown>, 'authorize'>
@@ -203,8 +211,14 @@ export async function v2HeadAuthorizationResponse(args: {
203211
request: NextRequest
204212
errorPolicy: V2ErrorPolicy
205213
}): Promise<NextResponse> {
214+
const { authorize } = args.useCase
215+
if (typeof authorize !== 'function') {
216+
throw new Error(
217+
'HEAD on a route that is not head-safe reached a use case with no authorize(); answering 200 would leak the existence of a resource the GET never authorized.'
218+
)
219+
}
206220
try {
207-
await args.useCase.authorize?.({
221+
await authorize({
208222
principal: args.principal,
209223
input: args.input,
210224
request: args.request,

apps/sim/lib/folders/queries.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
listActiveFolderRows,
1818
listFoldersForWorkspace,
1919
loadActiveFolderPathIndex,
20+
resolveFolderPathFilter,
2021
resolveRestoredFolderId,
2122
toFolderApi,
2223
wouldCreateFolderCycle,
@@ -318,6 +319,40 @@ describe('folder queries', () => {
318319
})
319320
})
320321

322+
/**
323+
* The one place the real helper is exercised. Every list use case that filters
324+
* by `folderPath` mocks this module out and stands a reimplementation in for
325+
* it, so a defect here — a miss widening to unfiltered, a root path that stops
326+
* resolving — would leave all of those suites green while every filtered list
327+
* answered with the wrong rows.
328+
*/
329+
describe('resolveFolderPathFilter', () => {
330+
const index = {
331+
pathById: new Map([['f-1', 'Reports']]),
332+
idByPath: new Map([['Reports', 'f-1']]),
333+
}
334+
335+
it('treats an omitted path as no filter at all', () => {
336+
expect(resolveFolderPathFilter(index, undefined)).toEqual({ kind: 'unfiltered' })
337+
})
338+
339+
it('resolves the root path to the workspace root rather than to a folder id', () => {
340+
expect(resolveFolderPathFilter(index, '/')).toEqual({ kind: 'folder', folderId: null })
341+
})
342+
343+
it('resolves a named path to its folder id', () => {
344+
expect(resolveFolderPathFilter(index, 'Reports')).toEqual({ kind: 'folder', folderId: 'f-1' })
345+
})
346+
347+
/**
348+
* A path naming no active folder narrows the list to nothing. Widening it to
349+
* `unfiltered` would answer a scoped read with every row in the workspace.
350+
*/
351+
it('narrows to nothing for a path that names no active folder', () => {
352+
expect(resolveFolderPathFilter(index, 'Archive')).toEqual({ kind: 'noMatch' })
353+
})
354+
})
355+
321356
describe('toFolderApi', () => {
322357
it('serializes timestamps to ISO strings and preserves a null deletedAt', () => {
323358
expect(toFolderApi(ROW)).toMatchObject({

apps/sim/lib/skills/application/operations.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ describe('skill operation registry', () => {
2727
* The invariant the create/delete split violated. A principal kind that can
2828
* create a skill must be able to remove it, or its only possible interaction
2929
* with the resource is to accumulate rows it can never reach again.
30+
*
31+
* Symmetry alone is not the property. A lifecycle that uniformly ALLOWED a
32+
* workspace key is just as symmetric and reopens the hole, because the edit
33+
* paths cannot resolve an acting subject for one. So both halves are pinned:
34+
* the writes agree on a policy, and the policy they agree on is the one every
35+
* edit path can honour. The principal kinds are compared directly rather than
36+
* left to the test's own name.
3037
*/
3138
it('admits the same principal kinds to every write in the lifecycle', () => {
3239
const writes = [
@@ -35,9 +42,11 @@ describe('skill operation registry', () => {
3542
skillOperations.upsert,
3643
skillOperations.delete,
3744
]
38-
const policies = writes.map((operation) => operation.workspaceApiKey)
3945

40-
expect(new Set(policies).size).toBe(1)
46+
expect(new Set(writes.map((operation) => operation.workspaceApiKey))).toEqual(new Set(['deny']))
47+
for (const operation of writes) {
48+
expect(operation.principalKinds).toEqual(skillOperations.delete.principalKinds)
49+
}
4150
})
4251

4352
it('gates every edit path on a human subject rather than workspace role', () => {

apps/sim/lib/table/rows/cursor.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,5 +218,27 @@ describe('tokens minted before the filter stamp', () => {
218218
expect(() => assertCursorQueryBinding(decoded, { predicate: ACTIVE })).toThrow(
219219
/Restart paging without the cursor/
220220
)
221+
/**
222+
* The code, not just the wording, is what a bumped `CURSOR_VERSION` would
223+
* cost: every in-flight token would fail `INVALID_CURSOR` at decode instead,
224+
* including the unfiltered ones that resume fine today.
225+
*/
226+
try {
227+
assertCursorQueryBinding(decoded, { predicate: ACTIVE })
228+
expect.unreachable('a re-filtered replay must be refused')
229+
} catch (e) {
230+
expect((e as TableQueryValidationError).code).toBe('CURSOR_FILTER_CONFLICT')
231+
}
232+
})
233+
234+
/**
235+
* The version a token minted today carries. Pinned so a bump is a deliberate
236+
* edit here rather than a silent one that strands every cursor a running
237+
* deploy already handed out.
238+
*/
239+
it('mints tokens at the version the previous deploy could already read', () => {
240+
const token = encodeCursor({ lastRow: ROW, keysetValid: true, nextOffset: 10 })
241+
242+
expect(JSON.parse(Buffer.from(token, 'base64url').toString('utf8')).v).toBe(1)
221243
})
222244
})

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { describe, expect, it } from 'vitest'
6+
import { LOCAL_UPLOAD_METADATA_SUFFIX } from '@/lib/uploads/core/storage-key'
67
import {
78
findWorkspaceFileRecord,
89
generateWorkspaceFileKey,
@@ -92,12 +93,26 @@ describe('workspace file reference normalization', () => {
9293
})
9394
})
9495

96+
/**
97+
* The only place the real key builder is measured — the upload-session suites
98+
* stand a stub in for it — so the budget is asserted here the way the filesystem
99+
* enforces it. Local storage writes a metadata sidecar beside the object under
100+
* the object's own name, so `NAME_MAX` bounds the key's last component PLUS that
101+
* suffix, not the component alone. Measuring the component alone passes with the
102+
* sidecar reservation removed, and the overflow returns as an `ENAMETOOLONG` 500
103+
* on a name the contract already admitted.
104+
*/
95105
describe('workspace file storage keys', () => {
96-
it('keeps the last key component within one path component for the longest admitted name', () => {
106+
/** POSIX `NAME_MAX`, in bytes, for one path component. */
107+
const NAME_MAX = 255
108+
109+
it('leaves the longest admitted name room for its local sidecar', () => {
97110
const key = generateWorkspaceFileKey('ws_123', `${'a'.repeat(251)}.txt`)
98111
const lastSegment = key.slice(key.lastIndexOf('/') + 1)
99112

100-
expect(Buffer.byteLength(lastSegment, 'utf-8')).toBeLessThanOrEqual(255)
113+
expect(
114+
Buffer.byteLength(`${lastSegment}${LOCAL_UPLOAD_METADATA_SUFFIX}`, 'utf-8')
115+
).toBeLessThanOrEqual(NAME_MAX)
101116
expect(key.startsWith('workspace/ws_123/')).toBe(true)
102117
expect(lastSegment.endsWith('.txt')).toBe(true)
103118
})

apps/sim/lib/uploads/upload-session/provider.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'
4+
import { link, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

7+
/**
8+
* Spied rather than replaced: every assertion in this file reads the real
9+
* filesystem, and the only behaviour worth faking is a single `link` answering
10+
* `EXDEV`, which no temporary directory can be made to produce on its own.
11+
*/
12+
vi.mock('node:fs/promises', { spy: true })
13+
714
const { testUploadDirectory, mockS3Presign, mockS3PartUrls } = vi.hoisted(() => ({
815
testUploadDirectory: `/tmp/sim-upload-session-provider-${process.pid}`,
916
mockS3Presign: vi.fn(),
@@ -117,6 +124,60 @@ describe('local upload-session provider', () => {
117124
})
118125
})
119126

127+
/**
128+
* Staging moved out of the destination's own directory into one shared
129+
* `.staging` root, which is what makes this reachable: a volume mounted under
130+
* part of the uploads tree puts the staged object and its destination on
131+
* different devices, and a hard link cannot span them. Publication has to
132+
* survive that without giving up the create-or-fail the link provides.
133+
*/
134+
it('publishes across a filesystem boundary a hard link cannot span', async () => {
135+
vi.mocked(link).mockRejectedValueOnce(
136+
Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' })
137+
)
138+
139+
await writeLocalPutObject({
140+
uploadId: 'upload-1',
141+
key: 'workspace/workspace-1/file.bin',
142+
body: byteStream('ab', 'cd'),
143+
expectedSize: 4,
144+
contentType: 'application/octet-stream',
145+
metadata: METADATA,
146+
})
147+
148+
await expect(readFile(localPath('workspace/workspace-1/file.bin'), 'utf8')).resolves.toBe(
149+
'abcd'
150+
)
151+
await expect(
152+
headProviderObject({
153+
provider: 'local',
154+
key: 'workspace/workspace-1/file.bin',
155+
context: CONTEXT,
156+
})
157+
).resolves.toMatchObject({ size: 4, uploadId: 'upload-1' })
158+
expect(await temporaryFiles('workspace/workspace-1')).toEqual([])
159+
expect(await allEntries('.staging')).toEqual([])
160+
})
161+
162+
it('still refuses to overwrite an existing object when the link cannot span devices', async () => {
163+
const params = {
164+
uploadId: 'upload-1',
165+
key: 'workspace/workspace-1/file.bin',
166+
expectedSize: 3,
167+
contentType: 'application/octet-stream',
168+
metadata: METADATA,
169+
}
170+
await writeLocalPutObject({ ...params, body: byteStream('one') })
171+
vi.mocked(link).mockRejectedValueOnce(
172+
Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' })
173+
)
174+
175+
await expect(writeLocalPutObject({ ...params, body: byteStream('two') })).rejects.toThrow()
176+
177+
await expect(readFile(localPath(params.key), 'utf8')).resolves.toBe('one')
178+
expect(await temporaryFiles('workspace/workspace-1')).toEqual([])
179+
})
180+
120181
it('does not let a replayed PUT overwrite the final object', async () => {
121182
const params = {
122183
uploadId: 'upload-1',

0 commit comments

Comments
 (0)