import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; const { peers, resetEvents, acknowledgeResetEvent, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({ peers: vi.fn(), resetEvents: vi.fn(), acknowledgeResetEvent: vi.fn(), resetPeer: vi.fn(), initiatePeering: vi.fn(), deleteUser: vi.fn(), addToast: vi.fn(), })); vi.mock('../../../api/client', async () => { // Re-export the REAL HttpError so the component's `err instanceof HttpError` // classifier and the test's constructed rejection share one class identity. const actual = await vi.importActual('../../../api/client'); return { ...actual, api: { federation: { peers, approvalRequests: vi.fn().mockResolvedValue({ requests: [] }), resetEvents, acknowledgeResetEvent, resetPeer, initiatePeering, }, admin: { deleteUser, }, }, }; }); vi.mock('../../../stores/uiStore', () => ({ useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }), })); vi.mock('../../../hooks/useWebSocket', () => ({ onFederationPeersChanged: () => () => {}, onFederationPeerResetDetected: () => () => {}, })); import { FederationPanel } from './FederationPanel'; import { HttpError } from '../../../api/client'; const resetPeerFixture = { id: 'p1', origin: 'https://peer.example', instanceName: 'Peer', status: 'needs_attention' as const, needsAttentionReason: 'peer_reset_detected' as const, lastSeenAt: Date.now(), lastFailureAt: null, consecutiveFailures: 0, consecutiveAuthFailures: 0, lastSyncedAt: Date.now(), autoRotateIntervalDays: 90, secretRotatedAt: null, rotationInProgress: false, createdAt: Date.now(), }; function orphanedAccount(overrides: Record = {}) { return { id: 'acc1', username: 'ghost', displayName: 'Ghost', avatarColor: null, ownedSpaces: [], spaceMemberCount: 2, messageCount: 5, ...overrides, }; } function resetEvent( accounts: ReturnType[], overrides: Record = {}, ) { return { origin: 'https://peer.example', deadEpoch: 'epoch-old', newEpoch: 'epoch-new', detectedAt: Date.now(), resolvedAt: null, acknowledgedAt: null, stubCount: 3, orphanedAccountCount: accounts.length, orphanedAccounts: accounts, ...overrides, }; } describe('FederationPanel — Reset cleanup', () => { beforeEach(() => { peers.mockReset(); resetEvents.mockReset(); acknowledgeResetEvent.mockReset(); resetPeer.mockReset(); initiatePeering.mockReset(); deleteUser.mockReset(); addToast.mockReset(); }); it('shows a Re-peer banner for a peer_reset_detected peer and re-peers on click', async () => { peers.mockResolvedValue({ peers: [resetPeerFixture] }); resetEvents.mockResolvedValue({ events: [] }); resetPeer.mockResolvedValue({ success: true }); initiatePeering.mockResolvedValue({ peer: resetPeerFixture }); render(); // The banner is a persistent surface — its "Re-peer" button is directly visible. const repeerBtn = await screen.findByRole('button', { name: 'Re-peer' }); fireEvent.click(repeerBtn); // Confirm the warning dialog. const confirmBtn = await screen.findByRole('button', { name: 'Re-peer & heal' }); fireEvent.click(confirmBtn); await waitFor(() => expect(resetPeer).toHaveBeenCalledWith('p1')); await waitFor(() => expect(initiatePeering).toHaveBeenCalledWith({ remoteOrigin: 'https://peer.example' }), ); // Order matters: reset BEFORE initiate. expect(resetPeer.mock.invocationCallOrder[0]).toBeLessThan( initiatePeering.mock.invocationCallOrder[0], ); }); it('warns when Re-peer completes but the handshake is unverified (verified:false)', async () => { peers.mockResolvedValue({ peers: [resetPeerFixture] }); resetEvents.mockResolvedValue({ events: [] }); resetPeer.mockResolvedValue({ success: true }); initiatePeering.mockResolvedValue({ peer: { ...resetPeerFixture, status: 'needs_attention' }, verified: false, }); render(); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' })); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' })); await waitFor(() => expect(initiatePeering).toHaveBeenCalled()); await waitFor(() => expect(addToast).toHaveBeenCalledWith( 'Re-peer incomplete — Peer still holds stale peering for you. Its admin must reset their side, then Re-peer again.', 'warning', ), ); expect(addToast).not.toHaveBeenCalledWith( expect.stringContaining('Re-peering initiated'), 'success', expect.anything(), ); }); it('warns when the remote rejects with 409 PEER_EXISTS_RESET_REQUIRED', async () => { peers.mockResolvedValue({ peers: [resetPeerFixture] }); resetEvents.mockResolvedValue({ events: [] }); resetPeer.mockResolvedValue({ success: true }); initiatePeering.mockRejectedValue( new HttpError(409, 'Peer exists — reset required', { error: 'Peer exists — reset required', statusCode: 409, code: 'PEER_EXISTS_RESET_REQUIRED', }), ); render(); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' })); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' })); await waitFor(() => expect(initiatePeering).toHaveBeenCalled()); await waitFor(() => expect(addToast).toHaveBeenCalledWith( 'The remote instance still holds stale peering for you. Ask its admin to reset their side, then Re-peer again.', 'warning', ), ); }); it('shows the success toast when the handshake is verified (verified:true)', async () => { peers.mockResolvedValue({ peers: [resetPeerFixture] }); resetEvents.mockResolvedValue({ events: [] }); resetPeer.mockResolvedValue({ success: true }); initiatePeering.mockResolvedValue({ peer: { ...resetPeerFixture, status: 'active' }, verified: true, }); render(); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer' })); fireEvent.click(await screen.findByRole('button', { name: 'Re-peer & heal' })); await waitFor(() => expect(initiatePeering).toHaveBeenCalled()); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Re-peering initiated with Peer', 'success', 3000), ); expect(addToast).not.toHaveBeenCalledWith( expect.stringContaining('still holds stale peering'), 'warning', ); }); it('removes an orphaned account via the existing admin delete', async () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); deleteUser.mockResolvedValue({ success: true }); render(); const removeBtn = await screen.findByRole('button', { name: 'Remove' }); fireEvent.click(removeBtn); const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' }); fireEvent.click(confirmBtn); await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1')); }); it('surfaces "transfer ownership first" only when the 400 carries an ownedSpaces payload', async () => { peers.mockResolvedValue({ peers: [] }); // Isolate the classifier: the fixture account does NOT own spaces locally // (default ownedSpaces: []), so the transfer-first message can only come // from the error's `ownedSpaces` payload — the real server 400 shape. resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); deleteUser.mockRejectedValue( new HttpError(400, 'User owns spaces — transfer ownership first', { error: 'User owns spaces — transfer ownership first', statusCode: 400, ownedSpaces: [{ id: 's1', name: 'My Space' }], }), ); render(); const removeBtn = await screen.findByRole('button', { name: 'Remove' }); fireEvent.click(removeBtn); const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' }); fireEvent.click(confirmBtn); await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1')); await waitFor(() => expect(addToast).toHaveBeenCalledWith( expect.stringContaining('transfer ownership first'), 'warning', ), ); }); it('shows the generic failure toast for a rejection WITHOUT an ownedSpaces payload', async () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); deleteUser.mockRejectedValue( new HttpError(400, 'Internal server error', { error: 'Internal server error', statusCode: 400 }), ); render(); const removeBtn = await screen.findByRole('button', { name: 'Remove' }); fireEvent.click(removeBtn); const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' }); fireEvent.click(confirmBtn); await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Internal server error', 'warning'), ); // The classifier must NOT mislabel a generic 400 as an ownership problem. expect(addToast).not.toHaveBeenCalledWith( expect.stringContaining('transfer ownership first'), 'warning', ); }); it('renders the detached-accounts card with Dismiss + Remove and informational copy, no Keep/frozen', async () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); render(); // Both real actions are present. await screen.findByRole('button', { name: /Dismiss/ }); expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); // Informational detach copy — not urgent-cleanup language. expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0); expect(screen.getByText(/existing password/i)).toBeInTheDocument(); // The fake client-only Keep/frozen affordance is fully gone. expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument(); expect(screen.queryByText(/frozen/i)).not.toBeInTheDocument(); // No "orphaned" urgency wording in the detached-accounts copy. expect(screen.queryByText(/with local content orphaned/i)).not.toBeInTheDocument(); }); it('does not render an acknowledged event and excludes it from the badge count', async () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })], }); render(); // Give effects a chance to run, then assert the whole section stays absent. await waitFor(() => expect(resetEvents).toHaveBeenCalled()); await waitFor(() => expect(screen.queryByText('Reset Cleanup')).not.toBeInTheDocument(), ); expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(); }); it('dismisses an event via the acknowledge API and re-fetches', async () => { peers.mockResolvedValue({ peers: [] }); // First load: unacknowledged. After acknowledge, re-fetch returns it acknowledged. resetEvents .mockResolvedValueOnce({ events: [resetEvent([orphanedAccount()])] }) .mockResolvedValue({ events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })] }); acknowledgeResetEvent.mockResolvedValue({ success: true }); render(); const dismissBtn = await screen.findByRole('button', { name: /Dismiss/ }); fireEvent.click(dismissBtn); await waitFor(() => expect(acknowledgeResetEvent).toHaveBeenCalledWith('https://peer.example'), ); // fetchAll re-runs after acknowledge (peers + resetEvents both hit twice). await waitFor(() => expect(resetEvents).toHaveBeenCalledTimes(2)); // The card disappears once the re-fetch marks the event acknowledged. await waitFor(() => expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(), ); }); it('surfaces an error toast when dismiss fails', async () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); acknowledgeResetEvent.mockRejectedValue(new Error('Network down')); render(); fireEvent.click(await screen.findByRole('button', { name: /Dismiss/ })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Network down', 'warning'), ); }); });