Skip to content

Commit 922ac91

Browse files
committed
fix(w6): close a head-authorization hole, a TZ leak, and five tests that could not fail
Six risks an adversarial read of this week's diff raised, verified one at a time. Two of the six were already correct and are reported as such rather than changed. `v2HeadAuthorizationResponse` optional-called the use case's authorization phase, so a use case without one would have answered the bodiless 200 that `headSafe: false` exists to prevent. The definition-time guard does cover both builders that reach it — they are its only callers — but an optional call turns a missing phase into that leak silently, so the responder now refuses instead of skipping. `packages/db/timestamps.test.ts` assigned `process.env.TZ` at module scope and never restored it. `TZ` is process state: a worker running files back to back carried Asia/Tokyo into every file that followed, and only when the ordering put it after this one. The zone is now set and restored around the file, with both properties the suite depends on intact. Upload publication moved its staging area out of the destination's own directory into a shared `.staging` root, which makes the publishing `link` a cross-subtree one. A volume mounted under part of the uploads tree puts the two on different devices and `link` answers `EXDEV`, which the same-directory link could not. Publication now copies onto the destination's device and links from there, keeping the create-or-fail step that stops a replay from overwriting a stored object. Five tests that passed regardless of the code: - `resolveFolderPathFilter` was only ever exercised through hand-written reimplementations in the suites that mock it out, so widening a miss to unfiltered — every filtered list answering with the whole workspace — left them all green. The real helper is now tested where it lives. - The only measurement of `generateWorkspaceFileKey` asserted the key's last component against `NAME_MAX` rather than the component plus the sidecar written beside it, so it passed with the sidecar reservation removed. - `GET /logs` asserted only that a rejected cursor does NOT name `sortBy`, which almost any wording satisfies, including one saying nothing at all. - The skills lifecycle test asserted that the four writes agree on a workspace-key policy, which a lifecycle uniformly allowing one also satisfies; it now pins the policy they agree on and the kinds they admit. - The v2 skills create test lost `expect(capture).not.toHaveBeenCalled()` when the create path moved to a personal key. The behaviour it pinned is gone — the workspace-key create is refused now — so it is re-homed as the refusal reaching the caller as a 403 with no analytics behind it. Two claims did not hold. `CURSOR_VERSION` is correctly left at 1: the filter stamp is additive, a pre-stamp token still decodes, an unfiltered read still resumes, and only a filtered replay fails — with a conflict that names the filter, where a version bump would answer a generic unreadable-cursor 400 to every in-flight token. Tests pin all three, plus the minted version itself. And the upload-session key-budget cases do exercise the real shared budget through the real segment builder; only the workspace-key prefix is the stub's, which is now stated where the stub is declared.
1 parent 75357db commit 922ac91

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)