Skip to content

Commit 8b3b41b

Browse files
refactor: enforce Copilot table application boundary (#6453)
* refactor: enforce copilot table application boundary * fix(tables): finish application boundary migration * fix(tables): restore scoped copilot imports * fix(tables): compose copilot commands atomically * fix(tables): preserve workflow group scheduling * fix(tables): complete fixed copilot composition * fix(tables): reject enrichment output mutation * fix(tables): complete authorized application boundary
1 parent 8c1f927 commit 8b3b41b

64 files changed

Lines changed: 7156 additions & 3043 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,27 @@
1-
import { type NextRequest, NextResponse } from 'next/server'
21
import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers'
3-
import { parseRequest } from '@/lib/api/server'
4-
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
5-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
62
import {
7-
createTableExportResource,
8-
toV2TableExport,
9-
} from '@/lib/table/orchestration/export-resource'
10-
import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils'
3+
defineInternalJsonRoute,
4+
internalPlainOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
} from '@/lib/api/server/routes'
7+
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
8+
import { createTableExportUseCase } from '@/lib/table/application/exports'
9+
import { tableOperations } from '@/lib/table/application/operations'
10+
import { toV2TableExport } from '@/lib/table/orchestration/export-resource'
1111

12-
interface TableRouteParams {
13-
params: Promise<{ tableId: string }>
14-
}
15-
16-
export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
17-
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
18-
if (!auth.success || !auth.userId) {
19-
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
20-
}
21-
const parsed = await parseRequest(createTableExportResourceContract, request, context)
22-
if (!parsed.success) return parsed.response
23-
const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read')
24-
if (!access.ok) return accessError(access, 'table-export')
25-
if (access.table.workspaceId !== parsed.data.body.workspaceId) {
26-
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
27-
}
28-
try {
29-
const record = await createTableExportResource({
30-
table: access.table,
31-
format: parsed.data.body.format,
32-
})
33-
return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 })
34-
} catch (error) {
35-
const classified = orchestrationErrorResponse(error)
36-
if (classified) return classified
37-
throw error
38-
}
12+
export const POST = defineInternalJsonRoute({
13+
contract: createTableExportResourceContract,
14+
auth: internalTableSessionOrExecutorAuth,
15+
operation: tableOperations.createExport,
16+
rateLimit: internalRateLimits.none({
17+
reason: 'Existing authenticated table export creation has no request-rate policy',
18+
}),
19+
errorPolicy: internalPlainOrchestrationErrorPolicy,
20+
mapInput: ({ params, body }) => ({
21+
tableId: params.tableId,
22+
workspaceId: body.workspaceId,
23+
format: body.format,
24+
}),
25+
useCase: createTableExportUseCase,
26+
present: ({ export: record }) => ({ data: toV2TableExport(record, true) }),
3927
})
Lines changed: 57 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -1,209 +1,73 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing'
5-
import { NextRequest } from 'next/server'
6-
import { beforeEach, describe, expect, it, vi } from 'vitest'
7-
import type { TableDefinition } from '@/lib/table'
4+
import { describe, expect, it, vi } from 'vitest'
85

9-
const { mockCheckAccess, mockAddWorkflowGroup, mockUpdateWorkflowGroup } = vi.hoisted(() => ({
10-
mockCheckAccess: vi.fn(),
11-
mockAddWorkflowGroup: vi.fn(),
12-
mockUpdateWorkflowGroup: vi.fn(),
13-
}))
6+
interface CapturedDefinition {
7+
contract: { method: string; path: string }
8+
auth: unknown
9+
operation: { id: string }
10+
useCase: unknown
11+
}
1412

15-
vi.mock('@/app/api/table/utils', async () => {
16-
const { NextResponse } = await import('next/server')
17-
return {
18-
accessError: (result: { status: number }) =>
19-
NextResponse.json({ error: 'denied' }, { status: result.status }),
20-
checkAccess: mockCheckAccess,
21-
normalizeColumn: (column: unknown) => column,
22-
}
23-
})
13+
const mocks = vi.hoisted(() => ({
14+
auth: { kind: 'session-or-executor' },
15+
definitions: [] as CapturedDefinition[],
16+
useCases: {
17+
create: { operation: { id: 'tables.groups.create' } },
18+
remove: { operation: { id: 'tables.groups.delete' } },
19+
update: { operation: { id: 'tables.groups.update' } },
20+
},
21+
}))
2422

25-
vi.mock('@/lib/table/workflow-groups/service', () => ({
26-
addWorkflowGroup: mockAddWorkflowGroup,
27-
updateWorkflowGroup: mockUpdateWorkflowGroup,
28-
deleteWorkflowGroup: vi.fn(),
23+
vi.mock('@/lib/api/server/routes', () => ({
24+
defineInternalJsonRoute: (definition: CapturedDefinition) => {
25+
mocks.definitions.push(definition)
26+
return vi.fn()
27+
},
28+
extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })),
29+
internalErrorResponse: vi.fn(),
30+
internalPlainOrchestrationErrorPolicy: { kind: 'plain' },
31+
internalRateLimits: {
32+
none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }),
33+
},
2934
}))
3035

31-
import { PATCH, POST } from '@/app/api/table/[tableId]/groups/route'
36+
vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth }))
3237

33-
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
34-
return {
35-
id: 'tbl_1',
36-
name: 'People',
37-
description: null,
38-
schema: { columns: [] },
39-
metadata: null,
40-
rowCount: 0,
41-
maxRows: 100,
42-
workspaceId: 'workspace-1',
43-
createdBy: 'user-1',
44-
archivedAt: null,
45-
createdAt: new Date('2024-01-01'),
46-
updatedAt: new Date('2024-01-01'),
47-
...overrides,
48-
}
49-
}
38+
vi.mock('@/lib/table/application/groups', () => ({
39+
createTableGroupUseCase: mocks.useCases.create,
40+
deleteTableGroupUseCase: mocks.useCases.remove,
41+
updateTableGroupUseCase: mocks.useCases.update,
42+
}))
5043

51-
function callPost(body: Record<string, unknown>, tableId = 'tbl_1') {
52-
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, {
53-
method: 'POST',
54-
body: JSON.stringify(body),
55-
headers: { 'Content-Type': 'application/json' },
56-
})
57-
return POST(req, { params: Promise.resolve({ tableId }) })
58-
}
44+
vi.mock('@/app/api/table/utils', () => ({
45+
normalizeColumn: vi.fn(),
46+
}))
5947

60-
function callPatch(body: Record<string, unknown>, tableId = 'tbl_1') {
61-
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, {
62-
method: 'PATCH',
63-
body: JSON.stringify(body),
64-
headers: { 'Content-Type': 'application/json' },
65-
})
66-
return PATCH(req, { params: Promise.resolve({ tableId }) })
67-
}
48+
import '@/app/api/table/[tableId]/groups/route'
6849

69-
const baseGroup = {
70-
id: 'grp_1',
71-
workflowId: 'wf_1',
72-
outputs: [{ blockId: 'block_1', path: 'result', columnName: 'result' }],
50+
function definition(method: string): CapturedDefinition {
51+
const match = mocks.definitions.find((candidate) => candidate.contract.method === method)
52+
if (!match) throw new Error(`Missing ${method} group route definition`)
53+
return match
7354
}
7455

75-
const baseOutputColumns = [{ name: 'result', type: 'string', workflowGroupId: 'grp_1' }]
76-
77-
describe('POST /api/table/[tableId]/groups', () => {
78-
beforeEach(() => {
79-
vi.clearAllMocks()
80-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
81-
success: true,
82-
userId: 'user-1',
83-
authType: 'session',
84-
})
85-
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
86-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({
87-
workflow: { id: 'wf_1' },
88-
workspaceId: 'workspace-1',
89-
workspaceOrganizationId: null,
90-
})
91-
mockAddWorkflowGroup.mockResolvedValue({
92-
schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] },
93-
})
94-
})
95-
96-
it('rejects a workflowId belonging to a different workspace', async () => {
97-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({
98-
workflow: { id: 'wf_1' },
99-
workspaceId: 'other-workspace',
100-
workspaceOrganizationId: null,
101-
})
102-
const res = await callPost({
103-
workspaceId: 'workspace-1',
104-
group: baseGroup,
105-
outputColumns: baseOutputColumns,
106-
})
107-
expect(res.status).toBe(400)
108-
expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
109-
})
110-
111-
it('rejects a nonexistent workflowId', async () => {
112-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null)
113-
const res = await callPost({
114-
workspaceId: 'workspace-1',
115-
group: baseGroup,
116-
outputColumns: baseOutputColumns,
117-
})
118-
expect(res.status).toBe(400)
119-
expect(mockAddWorkflowGroup).not.toHaveBeenCalled()
120-
})
121-
122-
it('succeeds when the workflow belongs to the same workspace', async () => {
123-
const res = await callPost({
124-
workspaceId: 'workspace-1',
125-
group: baseGroup,
126-
outputColumns: baseOutputColumns,
127-
})
128-
expect(res.status).toBe(200)
129-
expect(mockAddWorkflowGroup).toHaveBeenCalled()
130-
})
131-
132-
it('skips the workflow check for enrichment groups without a workflowId', async () => {
133-
const res = await callPost({
134-
workspaceId: 'workspace-1',
135-
group: { ...baseGroup, workflowId: '', enrichmentId: 'enrich_1' },
136-
outputColumns: baseOutputColumns,
137-
})
138-
expect(res.status).toBe(200)
139-
expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled()
140-
expect(mockAddWorkflowGroup).toHaveBeenCalled()
141-
})
142-
})
143-
144-
describe('PATCH /api/table/[tableId]/groups', () => {
145-
beforeEach(() => {
146-
vi.clearAllMocks()
147-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
148-
success: true,
149-
userId: 'user-1',
150-
authType: 'session',
151-
})
152-
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
153-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({
154-
workflow: { id: 'wf_1' },
155-
workspaceId: 'workspace-1',
156-
workspaceOrganizationId: null,
157-
})
158-
mockUpdateWorkflowGroup.mockResolvedValue({
159-
schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] },
160-
})
161-
})
162-
163-
it('rejects changing workflowId to one in a different workspace', async () => {
164-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({
165-
workflow: { id: 'wf_2' },
166-
workspaceId: 'other-workspace',
167-
workspaceOrganizationId: null,
168-
})
169-
const res = await callPatch({
170-
workspaceId: 'workspace-1',
171-
groupId: 'grp_1',
172-
workflowId: 'wf_2',
173-
})
174-
expect(res.status).toBe(400)
175-
expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled()
176-
})
177-
178-
it('rejects a nonexistent workflowId', async () => {
179-
workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null)
180-
const res = await callPatch({
181-
workspaceId: 'workspace-1',
182-
groupId: 'grp_1',
183-
workflowId: 'wf_missing',
184-
})
185-
expect(res.status).toBe(400)
186-
expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled()
187-
})
188-
189-
it('succeeds when changing workflowId to one in the same workspace', async () => {
190-
const res = await callPatch({
191-
workspaceId: 'workspace-1',
192-
groupId: 'grp_1',
193-
workflowId: 'wf_1',
194-
})
195-
expect(res.status).toBe(200)
196-
expect(mockUpdateWorkflowGroup).toHaveBeenCalled()
197-
})
198-
199-
it('skips the workflow check when workflowId is not being changed', async () => {
200-
const res = await callPatch({
201-
workspaceId: 'workspace-1',
202-
groupId: 'grp_1',
203-
name: 'Renamed group',
204-
})
205-
expect(res.status).toBe(200)
206-
expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled()
207-
expect(mockUpdateWorkflowGroup).toHaveBeenCalled()
56+
describe('/api/table/[tableId]/groups', () => {
57+
it('routes every mutation through its session-or-executor application use case', () => {
58+
const expected = [
59+
['POST', mocks.useCases.create],
60+
['PATCH', mocks.useCases.update],
61+
['DELETE', mocks.useCases.remove],
62+
] as const
63+
64+
expect(mocks.definitions).toHaveLength(expected.length)
65+
for (const [method, useCase] of expected) {
66+
const route = definition(method)
67+
expect(route.contract.path).toBe('/api/table/[tableId]/groups')
68+
expect(route.auth).toBe(mocks.auth)
69+
expect(route.useCase).toBe(useCase)
70+
expect(route.operation.id).toBe(useCase.operation.id)
71+
}
20872
})
20973
})

0 commit comments

Comments
 (0)