diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index bd79236e..37197629 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -277,7 +277,7 @@ export class BackspaceApiClient { }; readonly federation: { - initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>; + initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer; verified?: boolean }>; ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; resetEvents: () => Promise; @@ -697,7 +697,7 @@ export class BackspaceApiClient { this.federation = { initiatePeering: (data: { remoteOrigin: string }) => - request<{ peer: FederationPeer }>( + request<{ peer: FederationPeer; verified?: boolean }>( 'POST', '/federation/peer/initiate', data ), ensurePeered: (data: { remoteOrigin: string }) => diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx index 0badeeab..2aed34b2 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx @@ -122,6 +122,84 @@ describe('FederationPanel — Reset cleanup', () => { ); }); + 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()])] }); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 7a72e5ed..5bce34f1 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -910,8 +910,15 @@ function ResetCleanup() { // Order matters: reset the stale local record BEFORE the fresh handshake, // so activation heals stale friendships/DMs against the new incarnation. await api.federation.resetPeer(peer.id); - await api.federation.initiatePeering({ remoteOrigin: peer.origin }); - addToast(`Re-peering initiated with ${peerName(peer)}`, 'success', 3000); + const result = await api.federation.initiatePeering({ remoteOrigin: peer.origin }); + if (result.verified === false || result.peer?.status === 'needs_attention') { + addToast( + `Re-peer incomplete — ${peerName(peer)} still holds stale peering for you. Its admin must reset their side, then Re-peer again.`, + 'warning', + ); + } else { + addToast(`Re-peering initiated with ${peerName(peer)}`, 'success', 3000); + } await fetchAll(); } else { const { account } = confirmAction; @@ -938,6 +945,14 @@ function ResetCleanup() { } else { addToast(err instanceof Error ? err.message : 'Failed to remove account', 'warning'); } + } else if ( + err instanceof HttpError && err.status === 409 && + (err.body as { code?: string } | undefined)?.code === 'PEER_EXISTS_RESET_REQUIRED' + ) { + addToast( + `The remote instance still holds stale peering for you. Ask its admin to reset their side, then Re-peer again.`, + 'warning', + ); } else { addToast(err instanceof Error ? err.message : 'Re-peering failed', 'warning'); }