refactor(decnet_web/CanaryTokens): extract useCanaryTokens hook
Lift the parallel initial-load fetch and the deleteBlob mutation
off the page shell. Modal-driven optimistic merges (created
token, uploaded blob, drawer-revoked token) flow through narrow
setter helpers so the modals don't have to know how state is
shaped internally.
GET /canary/tokens
GET /canary/blobs (silent 403 -> empty list, viewer-friendly)
GET /deckies
GET /topologies/?status=active
DELETE /canary/blobs/:uuid
deleteBlob returns { ok, reason } so the page can branch the
toast/alert tone without seeing the axios error type. Wiring
into CanaryTokens.tsx lands in the next commit.
- New CanaryTokens/useCanaryTokens.ts
- useCanaryTokens.test.ts MSW-covers happy load, viewer 403 ->
empty blobs, deleteBlob ok + refused-with-detail paths, and the
markTokenRevoked optimistic write.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse, server, apiUrl } from '../../test/server';
|
||||
import { makeCanaryToken, makeCanaryBlob } from '../../test/fixtures';
|
||||
|
||||
import { useCanaryTokens } from './useCanaryTokens';
|
||||
|
||||
const stockHandlers = () => [
|
||||
http.get(apiUrl('/canary/tokens'), () =>
|
||||
HttpResponse.json({ tokens: [makeCanaryToken({ uuid: 't-1' })] }),
|
||||
),
|
||||
http.get(apiUrl('/canary/blobs'), () =>
|
||||
HttpResponse.json({ blobs: [makeCanaryBlob({ uuid: 'b-1' })] }),
|
||||
),
|
||||
http.get(apiUrl('/deckies'), () => HttpResponse.json([{ name: 'd1' }])),
|
||||
http.get(apiUrl('/topologies/'), () =>
|
||||
HttpResponse.json({ data: [{ id: 't-x', name: 'corp', status: 'active' }] }),
|
||||
),
|
||||
];
|
||||
|
||||
describe('useCanaryTokens', () => {
|
||||
it('loads tokens + blobs + deckies + topologies on mount', async () => {
|
||||
server.use(...stockHandlers());
|
||||
|
||||
const { result } = renderHook(() => useCanaryTokens());
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.tokens).toHaveLength(1);
|
||||
expect(result.current.blobs).toHaveLength(1);
|
||||
expect(result.current.deckies).toEqual([{ name: 'd1' }]);
|
||||
expect(result.current.topologies[0]?.id).toBe('t-x');
|
||||
});
|
||||
|
||||
it('silently treats viewer 403 on /canary/blobs as an empty list', async () => {
|
||||
server.use(
|
||||
...stockHandlers().filter((h) =>
|
||||
typeof h.info.path === 'string' && h.info.path !== apiUrl('/canary/blobs'),
|
||||
),
|
||||
http.get(apiUrl('/canary/blobs'), () =>
|
||||
HttpResponse.json({ detail: 'forbidden' }, { status: 403 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCanaryTokens());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.blobs).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteBlob returns ok and removes the blob from state', async () => {
|
||||
server.use(
|
||||
...stockHandlers(),
|
||||
http.delete(apiUrl('/canary/blobs/b-1'), () => HttpResponse.json({})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCanaryTokens());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.deleteBlob>> | undefined;
|
||||
await act(async () => {
|
||||
r = await result.current.deleteBlob('b-1');
|
||||
});
|
||||
expect(r).toEqual({ ok: true });
|
||||
expect(result.current.blobs).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteBlob surfaces server-side detail when refused', async () => {
|
||||
server.use(
|
||||
...stockHandlers(),
|
||||
http.delete(apiUrl('/canary/blobs/b-1'), () =>
|
||||
HttpResponse.json({ detail: 'still referenced by 3 tokens' }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCanaryTokens());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
let r: Awaited<ReturnType<typeof result.current.deleteBlob>> | undefined;
|
||||
await act(async () => {
|
||||
r = await result.current.deleteBlob('b-1');
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: 'still referenced by 3 tokens' });
|
||||
expect(result.current.blobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('markTokenRevoked flips the state to revoked', async () => {
|
||||
server.use(...stockHandlers());
|
||||
|
||||
const { result } = renderHook(() => useCanaryTokens());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => result.current.markTokenRevoked('t-1'));
|
||||
expect(result.current.tokens[0].state).toBe('revoked');
|
||||
});
|
||||
});
|
||||
113
decnet_web/src/components/CanaryTokens/useCanaryTokens.ts
Normal file
113
decnet_web/src/components/CanaryTokens/useCanaryTokens.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import type { CanaryTokenRow } from '../CanaryTokenDrawer';
|
||||
import type { BlobRow, DeckyOption, TopologyOption } from './types';
|
||||
import { extractError } from './helpers';
|
||||
|
||||
export type DeleteBlobResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface UseCanaryTokensResult {
|
||||
tokens: CanaryTokenRow[];
|
||||
blobs: BlobRow[];
|
||||
deckies: DeckyOption[];
|
||||
topologies: TopologyOption[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Re-fetch all four lists (tokens, blobs, deckies, topologies). */
|
||||
reload: () => Promise<void>;
|
||||
|
||||
/** Direct setters for optimistic merges from modals (CreateTokenModal,
|
||||
* UploadModal, drawer revoke). The hook doesn't try to be clever
|
||||
* about this — modals already have the row that came back from the
|
||||
* server, so they just slot it in. */
|
||||
prependToken: (t: CanaryTokenRow) => void;
|
||||
prependBlob: (b: BlobRow) => void;
|
||||
markTokenRevoked: (uuid: string) => void;
|
||||
/** DELETE /canary/blobs/:uuid; returns ok=false with the server's
|
||||
* detail string when refused (typically because tokens still
|
||||
* reference the blob). */
|
||||
deleteBlob: (uuid: string) => Promise<DeleteBlobResult>;
|
||||
}
|
||||
|
||||
/** Owns the initial parallel fetch of /canary/tokens, /canary/blobs,
|
||||
* /deckies, and /topologies/?status=active, plus the deleteBlob
|
||||
* mutation. The viewer-level 403 on /canary/blobs is silently
|
||||
* tolerated — viewers see an empty blob list rather than an error. */
|
||||
export function useCanaryTokens(): UseCanaryTokensResult {
|
||||
const [tokens, setTokens] = useState<CanaryTokenRow[]>([]);
|
||||
const [blobs, setBlobs] = useState<BlobRow[]>([]);
|
||||
const [deckies, setDeckies] = useState<DeckyOption[]>([]);
|
||||
const [topologies, setTopologies] = useState<TopologyOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [t, b, d, topos] = await Promise.all([
|
||||
api.get('/canary/tokens'),
|
||||
// Viewers can't list blobs — silently fall back so the
|
||||
// tokens tab still renders.
|
||||
api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })),
|
||||
api.get<DeckyOption[]>('/deckies').catch(() => ({ data: [] })),
|
||||
// Active topologies only — planting on a torn-down or pending
|
||||
// topology would 422/404 anyway. Trailing slash matters:
|
||||
// FastAPI's slash-redirect issues a 307 and the browser re-fires
|
||||
// without the Authorization header, landing as 401 on the
|
||||
// redirected URL. Hit /topologies/ directly.
|
||||
api.get('/topologies/?status=active').catch(() => ({ data: { data: [] } })),
|
||||
]);
|
||||
setTokens(t.data.tokens || []);
|
||||
setBlobs(b.data.blobs || []);
|
||||
setDeckies(Array.isArray(d.data) ? d.data : []);
|
||||
const topoRows: Array<{ id: string; name: string; status: string }> =
|
||||
topos.data?.data ?? [];
|
||||
setTopologies(topoRows.map((r) => ({ id: r.id, name: r.name, status: r.status })));
|
||||
} catch (err) {
|
||||
setError(extractError(err, 'Failed to load canary tokens.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const prependToken = useCallback((t: CanaryTokenRow) => {
|
||||
setTokens((prev) => [t, ...prev]);
|
||||
}, []);
|
||||
|
||||
const prependBlob = useCallback((b: BlobRow) => {
|
||||
setBlobs((prev) => prev.some((x) => x.uuid === b.uuid) ? prev : [b, ...prev]);
|
||||
}, []);
|
||||
|
||||
const markTokenRevoked = useCallback((uuid: string) => {
|
||||
setTokens((prev) =>
|
||||
prev.map((t) => (t.uuid === uuid ? { ...t, state: 'revoked' } : t)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteBlob = useCallback(async (uuid: string): Promise<DeleteBlobResult> => {
|
||||
try {
|
||||
await api.delete(`/canary/blobs/${encodeURIComponent(uuid)}`);
|
||||
setBlobs((prev) => prev.filter((b) => b.uuid !== uuid));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: extractError(err, 'Delete failed.') };
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
tokens, blobs, deckies, topologies, loading, error,
|
||||
reload, prependToken, prependBlob, markTokenRevoked, deleteBlob,
|
||||
}),
|
||||
[
|
||||
tokens, blobs, deckies, topologies, loading, error,
|
||||
reload, prependToken, prependBlob, markTokenRevoked, deleteBlob,
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user