Skip to content

Commit e026b97

Browse files
author
WordClaw Agent
committed
test: add tenant isolation coverage for agent run lifecycle
1 parent 533ca33 commit e026b97

File tree

2 files changed

+127
-2
lines changed

2 files changed

+127
-2
lines changed

src/__tests__/api-tenant.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22
import Fastify from 'fastify';
33
import { db } from '../db/index.js';
4-
import { apiKeys, domains, contentItems, contentTypes } from '../db/schema.js';
4+
import { agentRuns, apiKeys, domains, contentItems, contentTypes } from '../db/schema.js';
55
import { eq, sql } from 'drizzle-orm';
66
import apiRoutes from '../api/routes.js';
77
import { errorHandler } from '../api/error-handler.js';
@@ -325,4 +325,65 @@ describe('Multi-Tenant Domain Isolation Tests', () => {
325325
}
326326
}
327327
});
328+
329+
it('agent-run lifecycle routes enforce tenant scoping for read and control', async () => {
330+
let domain1RunId: number | null = null;
331+
332+
try {
333+
const createRun = await fastify.inject({
334+
method: 'POST',
335+
url: '/api/agent-runs',
336+
headers: { 'x-api-key': rawKey1 },
337+
payload: {
338+
goal: `domain1-run-${crypto.randomUUID()}`,
339+
runType: 'review_backlog_manager',
340+
requireApproval: true
341+
}
342+
});
343+
344+
expect(createRun.statusCode).toBe(201);
345+
const createPayload = JSON.parse(createRun.payload) as {
346+
data: { id: number; status: string };
347+
};
348+
domain1RunId = createPayload.data.id;
349+
expect(createPayload.data.status).toBe('waiting_approval');
350+
351+
const domain1Read = await fastify.inject({
352+
method: 'GET',
353+
url: `/api/agent-runs/${domain1RunId}`,
354+
headers: { 'x-api-key': rawKey1 }
355+
});
356+
expect(domain1Read.statusCode).toBe(200);
357+
358+
const domain2Read = await fastify.inject({
359+
method: 'GET',
360+
url: `/api/agent-runs/${domain1RunId}`,
361+
headers: { 'x-api-key': rawKey2 }
362+
});
363+
expect(domain2Read.statusCode).toBe(404);
364+
expect(JSON.parse(domain2Read.payload).code).toBe('AGENT_RUN_NOT_FOUND');
365+
366+
const domain2Control = await fastify.inject({
367+
method: 'POST',
368+
url: `/api/agent-runs/${domain1RunId}/control`,
369+
headers: { 'x-api-key': rawKey2 },
370+
payload: { action: 'cancel' }
371+
});
372+
expect(domain2Control.statusCode).toBe(404);
373+
expect(JSON.parse(domain2Control.payload).code).toBe('AGENT_RUN_NOT_FOUND');
374+
375+
const domain1Approve = await fastify.inject({
376+
method: 'POST',
377+
url: `/api/agent-runs/${domain1RunId}/control`,
378+
headers: { 'x-api-key': rawKey1 },
379+
payload: { action: 'approve' }
380+
});
381+
expect(domain1Approve.statusCode).toBe(200);
382+
expect(JSON.parse(domain1Approve.payload).data.status).toBe('running');
383+
} finally {
384+
if (domain1RunId) {
385+
await db.delete(agentRuns).where(eq(agentRuns.id, domain1RunId));
386+
}
387+
}
388+
});
328389
});

src/graphql/tenant-isolation-graphql.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
44
import { eq } from 'drizzle-orm';
55

66
import { db } from '../db/index.js';
7-
import { contentItems, contentTypes, domains } from '../db/schema.js';
7+
import { agentRuns, contentItems, contentTypes, domains } from '../db/schema.js';
88
import { resolvers } from './resolvers.js';
99
import { schema } from './schema.js';
1010

@@ -14,6 +14,7 @@ describe('GraphQL Tenant Isolation', () => {
1414
let domain2TypeId: number;
1515
let domain1ItemId: number;
1616
let domain2ItemId: number;
17+
let domain1RunId: number;
1718

1819
beforeAll(async () => {
1920
const [domain1] = await db.select().from(domains).where(eq(domains.id, 1));
@@ -76,6 +77,15 @@ describe('GraphQL Tenant Isolation', () => {
7677
}).returning();
7778
domain2ItemId = item2.id;
7879

80+
const [run1] = await db.insert(agentRuns).values({
81+
domainId: 1,
82+
goal: `domain-one-tenant-graphql-run-${Date.now()}`,
83+
runType: 'review_backlog_manager',
84+
status: 'waiting_approval',
85+
requestedBy: 'tenant-1'
86+
}).returning();
87+
domain1RunId = run1.id;
88+
7989
app = Fastify({ logger: false });
8090
app.register(mercurius, {
8191
schema,
@@ -117,6 +127,9 @@ describe('GraphQL Tenant Isolation', () => {
117127
if (domain2TypeId) {
118128
await db.delete(contentTypes).where(eq(contentTypes.id, domain2TypeId));
119129
}
130+
if (domain1RunId) {
131+
await db.delete(agentRuns).where(eq(agentRuns.id, domain1RunId));
132+
}
120133
});
121134

122135
it('rejects assigning a cross-tenant content type on updateContentItem', async () => {
@@ -219,4 +232,55 @@ describe('GraphQL Tenant Isolation', () => {
219232
expect(payload.data?.updateContentItemsBatch?.results[0]?.ok).toBe(false);
220233
expect(payload.data?.updateContentItemsBatch?.results[0]?.code).toBe('CONTENT_ITEM_NOT_FOUND');
221234
});
235+
236+
it('hides cross-tenant agent runs and rejects cross-tenant control actions', async () => {
237+
const queryResponse = await app.inject({
238+
method: 'POST',
239+
url: '/graphql',
240+
headers: {
241+
'x-domain-id': '2'
242+
},
243+
payload: {
244+
query: `
245+
query AgentRun($id: ID!) {
246+
agentRun(id: $id) { id status }
247+
}
248+
`,
249+
variables: {
250+
id: String(domain1RunId)
251+
}
252+
}
253+
});
254+
255+
expect(queryResponse.statusCode).toBe(200);
256+
const queryPayload = queryResponse.json() as {
257+
data?: { agentRun?: { id: string } | null };
258+
};
259+
expect(queryPayload.data?.agentRun).toBeNull();
260+
261+
const controlResponse = await app.inject({
262+
method: 'POST',
263+
url: '/graphql',
264+
headers: {
265+
'x-domain-id': '2'
266+
},
267+
payload: {
268+
query: `
269+
mutation ControlRun($id: ID!, $action: String!) {
270+
controlAgentRun(id: $id, action: $action) { id status }
271+
}
272+
`,
273+
variables: {
274+
id: String(domain1RunId),
275+
action: 'cancel'
276+
}
277+
}
278+
});
279+
280+
expect(controlResponse.statusCode).toBe(200);
281+
const controlPayload = controlResponse.json() as {
282+
errors?: Array<{ extensions?: { code?: string } }>;
283+
};
284+
expect(controlPayload.errors?.[0]?.extensions?.code).toBe('AGENT_RUN_NOT_FOUND');
285+
});
222286
});

0 commit comments

Comments
 (0)