Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/__tests__/unit/routes/grants.mint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ beforeEach(async () => {
});

describe('POST /api/grants', () => {
test('an agent User ID used as a pod target returns not-found before join-policy checks', async () => {
outsiderGrantPodRefusal.mockResolvedValue('pod_joinable');
const res = await mint({ target: { kind: 'pod', id: SEAT } });
expect(res.status).toBe(404);
expect(res.body).toMatchObject({ error: 'target_not_found', message: 'target pod not found' });
expect(outsiderGrantPodRefusal).not.toHaveBeenCalled();
expect(await RoomGrant.countDocuments({})).toBe(0);
});

test.each([
['pod_required', { kind: 'seat', id: SEAT }],
['pod_not_private', { kind: 'pod', id: POD }],
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/v2/__tests__/V2ConnectorTools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -783,3 +783,73 @@ test('a hosted connect start error uses fixed copy instead of response text', as
open.mockRestore();
}
});

test.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => {
mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
axios.post.mockRejectedValue({ response: { status, data: {
error: 'invalid_target', message: 'The selected target is unavailable.',
} } });
renderTools();
fireEvent.click(await screen.findByRole('button', { name: 'Add', exact: true }));
const form = await screen.findByRole('complementary', { name: 'Add Linear' });
fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.');
expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'],
}), expect.any(Object));
});

test.each([undefined, {}, { error: 'invalid_target' }, '<html>fallback</html>', { grantId: '' }, { grantId: ' ' }])(
'an unconfirmed mint response keeps the form and old grant: %p', async (body) => {
mockApi([githubEntry]);
axios.post.mockResolvedValue({ data: body });
renderTools();
fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' }));
fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' }))
.getByRole('button', { name: 'Change access' }));
const form = await screen.findByRole('complementary', { name: 'Change access' });
fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.');
expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument();
expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']);
},
);


test.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => {
mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.';
axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } });
renderTools();
fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
const form = await screen.findByRole('complementary', { name: 'Add Linear' });
fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
expect(await within(form).findByRole('alert')).toHaveTextContent(message);
const previousGet = axios.get.getMockImplementation();
axios.get.mockImplementation((url, ...args) => url === '/api/installables'
? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } }))
: previousGet(url, ...args));
const reads = axios.get.mock.calls.length;
await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads));
expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent(message);
expect(screen.getByText('0 granted')).toBeInTheDocument();
});

test('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => {
mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } });
const previousGet = axios.get.getMockImplementation();
axios.get.mockImplementation((url, ...args) => url === '/api/registry/pods/p2/agents'
? Promise.resolve({ data: { agents: [{ name: 'reed', userId: 'a2' }] } })
: previousGet(url, ...args));
renderTools();
fireEvent.click(await screen.findByRole('button', { name: 'Add' }));
const form = await screen.findByRole('complementary', { name: 'Add Linear' });
fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } });
fireEvent.click(within(form).getByRole('button', { name: 'Grant' }));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({
target: { kind: 'pod', id: 'p2' }, audience: ['a2'],
}), expect.anything()));
});
19 changes: 15 additions & 4 deletions frontend/src/v2/components/V2ConnectorTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ interface Props {
}

interface DraftGrant {
installableId: string;
entry: ToolCatalogEntry;
podId: string;
connectionId: string;
writeMode: GrantWriteMode;
Expand Down Expand Up @@ -426,7 +426,7 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
const podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || '');
const modes = entryGrantModes(entry);
setDraft({
installableId: entry.installableId,
entry,
podId,
connectionId: entry.connections[0]?.connectionId || '',
writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read',
Expand All @@ -447,7 +447,12 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
setConfirmRevoke(null);
setError(null);
};
const draftEntry = draft ? catalog.find((entry) => entry.installableId === draft.installableId) || null : null;
// A background catalogue read can fail or stop offering this connection.
// Keep the open draft (and any server refusal) visible in either case. The
// mint still checks ownership, connection state, tools, audience and pod policy.
const draftEntry = draft
? catalog.find((entry) => entry.installableId === draft.entry.installableId) || draft.entry
: null;
const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || [])
.filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name);

Expand All @@ -469,6 +474,12 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
audience: draft.audience,
expiresAt,
});
// A resolved request alone is not a mint receipt (for example, an HTML
// fallback or empty response). Keep the form and the old grant intact.
if (typeof minted?.grantId !== 'string' || !minted.grantId.trim()) {
setError(t('tools.grantError', { defaultValue: 'Could not grant it.' }));
return;
}
// Change access: a change is a new grant and a revoke of the old one, because `tools` is never
// widened in place. The mint is kept whatever happens next; a failed revoke is retried alone
// (Vera on 0368992e), never by minting a third grant.
Expand Down Expand Up @@ -1063,7 +1074,7 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
if (grants === null) {
return <div className="v2-connectors__loading">{t('tools.loading', { defaultValue: 'Loading tools…' })}</div>;
}
if (grants.length === 0 && catalog.length === 0 && hiddenConnections.length === 0 && !removedHiddenNotice) return null;
if (grants.length === 0 && catalog.length === 0 && hiddenConnections.length === 0 && !removedHiddenNotice && !draft) return null;

return (
<div className="v2-connectors__content v2-tools">
Expand Down
Loading