feat(solo): enqueueClaudeJobsBatchAction met IDOR-check
Voeg enqueueClaudeJobsBatchAction toe: accepteert expliciete taskIds[], verifieert dat alle IDs bij de ingelogde gebruiker horen (IDOR-preventie), slaat taken met actieve jobs over (idempotent), en maakt jobs aan in transactie in opgegeven volgorde. 6 nieuwe tests.
This commit is contained in:
parent
8018920cae
commit
3ca842ff80
2 changed files with 165 additions and 0 deletions
|
|
@ -50,6 +50,7 @@ import {
|
||||||
enqueueAllTodoJobsAction,
|
enqueueAllTodoJobsAction,
|
||||||
cancelClaudeJobAction,
|
cancelClaudeJobAction,
|
||||||
previewEnqueueAllAction,
|
previewEnqueueAllAction,
|
||||||
|
enqueueClaudeJobsBatchAction,
|
||||||
} from '@/actions/claude-jobs'
|
} from '@/actions/claude-jobs'
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||||
|
|
@ -295,6 +296,95 @@ describe('previewEnqueueAllAction', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||||
|
id,
|
||||||
|
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enqueueClaudeJobsBatchAction', () => {
|
||||||
|
it('happy path: 3 taskIds → 3 jobs in input order', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('t1'),
|
||||||
|
makeBatchTask('t2'),
|
||||||
|
makeBatchTask('t3'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-1', task_id: 't1' },
|
||||||
|
{ id: 'job-2', task_id: 't2' },
|
||||||
|
{ id: 'job-3', task_id: 't3' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 3 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks demo user', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when product not accessible', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when task belongs to another user (IDOR)', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
// Only 1 of 2 tasks authorized (other-user's task filtered out)
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('t1')])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips tasks with active jobs (idempotent)', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('t1'),
|
||||||
|
makeBatchTask('t2', true), // has active job — skip
|
||||||
|
makeBatchTask('t3'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-1', task_id: 't1' },
|
||||||
|
{ id: 'job-3', task_id: 't3' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns count=0 for empty taskIds', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, [])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 0 })
|
||||||
|
expect(mockFindFirstProduct).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cancelClaudeJobAction', () => {
|
describe('cancelClaudeJobAction', () => {
|
||||||
it('happy path: cancels QUEUED job', async () => {
|
it('happy path: cancels QUEUED job', async () => {
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,81 @@ export async function previewEnqueueAllAction(productId: string): Promise<Prefli
|
||||||
return { tasks, blockerIndex, blockerReason }
|
return { tasks, blockerIndex, blockerReason }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enqueueClaudeJobsBatchAction(
|
||||||
|
productId: string,
|
||||||
|
taskIds: string[]
|
||||||
|
): Promise<EnqueueAllResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
if (!productId) return { error: 'product_id is verplicht' }
|
||||||
|
if (!taskIds.length) return { success: true, count: 0 }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||||
|
|
||||||
|
const userId = session.userId
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: productId, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||||
|
|
||||||
|
const authorizedTasks = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: taskIds },
|
||||||
|
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
claude_jobs: {
|
||||||
|
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authorizedTasks.length !== taskIds.length) {
|
||||||
|
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
||||||
|
if (queueable.length === 0) return { success: true, count: 0 }
|
||||||
|
|
||||||
|
const queueableIds = new Set(queueable.map(t => t.id))
|
||||||
|
const orderedQueueable = taskIds.filter(id => queueableIds.has(id))
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
orderedQueueable.map(taskId =>
|
||||||
|
prisma.claudeJob.create({
|
||||||
|
data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||||
|
select: { id: true, task_id: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const job of created) {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||||
|
type: 'claude_job_enqueued',
|
||||||
|
job_id: job.id,
|
||||||
|
task_id: job.task_id,
|
||||||
|
user_id: userId,
|
||||||
|
product_id: productId,
|
||||||
|
status: 'queued',
|
||||||
|
})}::text)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/products/${productId}/solo`)
|
||||||
|
return { success: true, count: created.length }
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue