diff --git a/decnet_web/src/components/CanaryTokens/useCanaryTokens.test.ts b/decnet_web/src/components/CanaryTokens/useCanaryTokens.test.ts new file mode 100644 index 00000000..7b29af0c --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/useCanaryTokens.test.ts @@ -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> | 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> | 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'); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/useCanaryTokens.ts b/decnet_web/src/components/CanaryTokens/useCanaryTokens.ts new file mode 100644 index 00000000..6e224577 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/useCanaryTokens.ts @@ -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; + + /** 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; +} + +/** 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([]); + const [blobs, setBlobs] = useState([]); + const [deckies, setDeckies] = useState([]); + const [topologies, setTopologies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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('/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 => { + 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, + ], + ); +}