diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index a5521bd37c..15478037b6 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -106,17 +106,20 @@ describe('POST /api/grants', () => { expect(res.body.audience).toEqual([OWNER, SEAT]); }); - test.each([{ audience: [] }, { audience: [OWNER] }, { audience: [OWNER, SEAT] }])('a seat audience must still be exactly its target: %p', async ({ audience }) => { - const res = await mint({ target: { kind: 'seat', id: SEAT }, audience }); + // TASK-019: create mints pod grants only. A seat target used to pass the + // target check and reach 201 here only because the outsider gate is mocked + // to null; the real gate refuses every non-pod target. + test.each([{ audience: undefined }, { audience: [SEAT] }, { audience: [OWNER, SEAT] }])('a seat target is refused at the target check: %p', async ({ audience }) => { + const res = await mint({ target: { kind: 'seat', id: SEAT }, ...(audience === undefined ? {} : { audience }) }); expect(res.status).toBe(400); - expect(res.body.error).toBe('invalid_audience'); + expect(res.body).toMatchObject({ error: 'invalid_target', message: 'target.kind must be pod' }); + expect(outsiderGrantPodRefusal).not.toHaveBeenCalled(); expect(await RoomGrant.countDocuments({})).toBe(0); }); - test.each([{ audience: undefined }, { audience: [SEAT] }])('a seat defaults to itself or accepts itself explicitly: %p', async ({ audience }) => { - const res = await mint({ target: { kind: 'seat', id: SEAT }, ...(audience === undefined ? {} : { audience }) }); - expect(res.status).toBe(201); - expect(res.body.audience).toEqual([SEAT]); + test('the real outsider gate refuses a seat target, so the check above loses nothing', async () => { + const real = jest.requireActual('../../../services/outsiderGrantPodEligibility'); + await expect(real.outsiderGrantPodRefusal({ kind: 'seat', id: SEAT })).resolves.toBe('pod_required'); }); test('an agent User ID used as a pod target returns not-found before join-policy checks', async () => { @@ -129,7 +132,9 @@ describe('POST /api/grants', () => { }); test.each([ - ['pod_required', { kind: 'seat', id: SEAT }], + // A seat now stops at the target check (above); the gate's pod_required + // still maps to its 403 for a pod it cannot resolve. + ['pod_required', { kind: 'pod', id: POD }], ['pod_not_private', { kind: 'pod', id: POD }], ['pod_bridged', { kind: 'pod', id: POD }], ['non_native_agent', { kind: 'pod', id: POD }], @@ -140,6 +145,7 @@ describe('POST /api/grants', () => { expect(res.status).toBe(403); expect(res.body).toMatchObject({ error: `outsider_grant_${reason}` }); expect(res.body.message).toMatch(/separate pod/); + expect(outsiderGrantPodRefusal).toHaveBeenCalledWith(target, 'read'); expect(await RoomGrant.countDocuments({})).toBe(0); }); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index 3bce56061e..361ade62e2 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -361,34 +361,24 @@ router.post('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: ex } const target = body.target; - if (!target || (target.kind !== 'pod' && target.kind !== 'seat')) { - return res.status(400).json({ error: 'invalid_target', message: 'target.kind must be pod or seat' }); + // Pod only. The outsider gate below refuses every non-pod target + // (pod_required), so a seat target could never be minted here; accepting it + // at this check only moved the refusal later and behind a misleading message. + if (!target || target.kind !== 'pod') { + return res.status(400).json({ error: 'invalid_target', message: 'target.kind must be pod' }); } const members = await ensureTargetAccess(target, userId); - // Pod grants default to the current member snapshot. A seat has no pod - // census, so its own seat id is the minimum audience and can be extended - // explicitly by the granter. - const audience = target.kind === 'seat' - ? (body.audience === undefined ? [target.id] : body.audience) - : (body.audience === undefined ? members : body.audience); + // Pod grants default to the current member snapshot. + const audience = body.audience === undefined ? members : body.audience; const audienceValues = Array.isArray(audience) ? audience.map(String) : audience; if ( - target.kind === 'seat' - && (!Array.isArray(audienceValues) - || audienceValues.length !== 1 - || audienceValues[0] !== String(target.id)) - ) { - return res.status(400).json({ error: 'invalid_audience', message: 'seat audience must be the target seat' }); - } - if ( - target.kind === 'pod' - && Array.isArray(audienceValues) + Array.isArray(audienceValues) && audienceValues.some((id: string) => !members.includes(id)) ) { return res.status(400).json({ error: 'invalid_audience', message: 'audience must be current target members' }); } - if (target.kind === 'pod' && Array.isArray(body.audience) && body.audience.length === 0) { + if (Array.isArray(body.audience) && body.audience.length === 0) { return res.status(400).json({ error: 'invalid_audience', message: 'name at least one agent, or omit audience for all current members',