fix(federation): carry error body on HttpError so Reset-cleanup owns-spaces copy reaches the UI
Also narrow SanitizedPeer.needsAttentionReason to the shared union.
This commit is contained in:
@@ -40,7 +40,7 @@ interface SanitizedPeer {
|
|||||||
rotationInProgress: boolean;
|
rotationInProgress: boolean;
|
||||||
secretRotatedAt: number | null;
|
secretRotatedAt: number | null;
|
||||||
autoRotateIntervalDays: number;
|
autoRotateIntervalDays: number;
|
||||||
needsAttentionReason: string | null;
|
needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer {
|
function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer {
|
||||||
@@ -57,7 +57,7 @@ function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): Sanitize
|
|||||||
rotationInProgress: row.pendingHmacSecret !== null,
|
rotationInProgress: row.pendingHmacSecret !== null,
|
||||||
secretRotatedAt: row.secretRotatedAt,
|
secretRotatedAt: row.secretRotatedAt,
|
||||||
autoRotateIntervalDays: row.autoRotateIntervalDays,
|
autoRotateIntervalDays: row.autoRotateIntervalDays,
|
||||||
needsAttentionReason: row.needsAttentionReason,
|
needsAttentionReason: row.needsAttentionReason as SanitizedPeer['needsAttentionReason'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ export class RateLimitError extends Error {
|
|||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
constructor(status: number, message: string) {
|
readonly body?: unknown;
|
||||||
|
constructor(status: number, message: string, body?: unknown) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'HttpError';
|
this.name = 'HttpError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +371,7 @@ export class BackspaceApiClient {
|
|||||||
throw new RateLimitError(retryAfter);
|
throw new RateLimitError(retryAfter);
|
||||||
}
|
}
|
||||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`);
|
throw new HttpError(response.status, (error as { error?: string }).error || `HTTP ${response.status}`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
|
|||||||
+22
-15
@@ -10,20 +10,26 @@ const { peers, resetEvents, resetPeer, initiatePeering, deleteUser, addToast } =
|
|||||||
addToast: vi.fn(),
|
addToast: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../api/client', () => ({
|
vi.mock('../../../api/client', async () => {
|
||||||
api: {
|
// Re-export the REAL HttpError so the component's `err instanceof HttpError`
|
||||||
federation: {
|
// classifier and the test's constructed rejection share one class identity.
|
||||||
peers,
|
const actual = await vi.importActual<typeof import('../../../api/client')>('../../../api/client');
|
||||||
approvalRequests: vi.fn().mockResolvedValue({ requests: [] }),
|
return {
|
||||||
resetEvents,
|
...actual,
|
||||||
resetPeer,
|
api: {
|
||||||
initiatePeering,
|
federation: {
|
||||||
|
peers,
|
||||||
|
approvalRequests: vi.fn().mockResolvedValue({ requests: [] }),
|
||||||
|
resetEvents,
|
||||||
|
resetPeer,
|
||||||
|
initiatePeering,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
deleteUser,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
};
|
||||||
deleteUser,
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../../stores/uiStore', () => ({
|
vi.mock('../../../stores/uiStore', () => ({
|
||||||
useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }),
|
useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }),
|
||||||
@@ -35,6 +41,7 @@ vi.mock('../../../hooks/useWebSocket', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { FederationPanel } from './FederationPanel';
|
import { FederationPanel } from './FederationPanel';
|
||||||
|
import { HttpError } from '../../../api/client';
|
||||||
|
|
||||||
const resetPeerFixture = {
|
const resetPeerFixture = {
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -138,7 +145,7 @@ describe('FederationPanel — Reset cleanup', () => {
|
|||||||
// from the error's `ownedSpaces` payload — the real server 400 shape.
|
// from the error's `ownedSpaces` payload — the real server 400 shape.
|
||||||
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
||||||
deleteUser.mockRejectedValue(
|
deleteUser.mockRejectedValue(
|
||||||
Object.assign(new Error('User owns spaces — transfer ownership first'), {
|
new HttpError(400, 'User owns spaces — transfer ownership first', {
|
||||||
error: 'User owns spaces — transfer ownership first',
|
error: 'User owns spaces — transfer ownership first',
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
ownedSpaces: [{ id: 's1', name: 'My Space' }],
|
ownedSpaces: [{ id: 's1', name: 'My Space' }],
|
||||||
@@ -166,7 +173,7 @@ describe('FederationPanel — Reset cleanup', () => {
|
|||||||
peers.mockResolvedValue({ peers: [] });
|
peers.mockResolvedValue({ peers: [] });
|
||||||
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
||||||
deleteUser.mockRejectedValue(
|
deleteUser.mockRejectedValue(
|
||||||
Object.assign(new Error('Internal server error'), { statusCode: 400 }),
|
new HttpError(400, 'Internal server error', { error: 'Internal server error', statusCode: 400 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<FederationPanel />);
|
render(<FederationPanel />);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useSettingsStore } from '../../../stores/settingsStore';
|
|||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
||||||
import { api } from '../../../api/client';
|
import { api, HttpError } from '../../../api/client';
|
||||||
import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket';
|
import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket';
|
||||||
import type { InstanceAdminSettings } from '@backspace/shared';
|
import type { InstanceAdminSettings } from '@backspace/shared';
|
||||||
import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client';
|
import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client';
|
||||||
@@ -926,7 +926,10 @@ function ResetCleanup() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (confirmAction.kind === 'remove') {
|
if (confirmAction.kind === 'remove') {
|
||||||
const ownsSpaces = err != null && typeof err === 'object' && 'ownedSpaces' in err;
|
const ownsSpaces =
|
||||||
|
err instanceof HttpError &&
|
||||||
|
err.status === 400 &&
|
||||||
|
Array.isArray((err.body as { ownedSpaces?: unknown } | undefined)?.ownedSpaces);
|
||||||
if (ownsSpaces) {
|
if (ownsSpaces) {
|
||||||
addToast(
|
addToast(
|
||||||
`${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`,
|
`${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`,
|
||||||
|
|||||||
Reference in New Issue
Block a user