fix(federation): ensurePeered must not auto-heal needs_attention peers
Discovered during live verification of #10b Scenario 3: the switch
in ensurePeered had no case for needs_attention, so it fell through
to performHandshake. Because the /peer/accept idempotent-200-no-update
safeguard covers needs_attention on the inbound side, the remote
returned 200 without writing the new secret, and performHandshake
transitioned the local peer to 'active' on the 200 response — auto-
healing a state that requires admin intervention.
Affected paths: sendCallRelay non-blocking warm-up (used by typing
relay); any future caller of ensurePeered on a needs_attention peer.
Not affected: resolvePendingPeers (already filters on status='pending').
Fix: explicit case 'needs_attention' returning { status: 'rejected',
error }. Caller observes the rejection and does not advance state.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { EnsurePeeredResult } from './federationPeering.js';
|
import type { EnsurePeeredResult } from './federationPeering.js';
|
||||||
import { racePeering } from './federationPeering.js';
|
import { racePeering } from './federationPeering.js';
|
||||||
|
|
||||||
@@ -112,3 +112,59 @@ describe('racePeering', () => {
|
|||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ensurePeered needs_attention handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns rejected without calling performHandshake when peer is in needs_attention', async () => {
|
||||||
|
const fakeDbGet = vi.fn().mockReturnValue({
|
||||||
|
id: 'peer-na',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
status: 'needs_attention',
|
||||||
|
hmacSecret: 'secret',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSyncedAt: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock('../db/index.js', () => ({
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
get: fakeDbGet,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../utils/federationAuth.js', () => ({
|
||||||
|
getOurOrigin: () => 'https://local.example',
|
||||||
|
generateHmacSecret: () => 'new-secret',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../routes/federation.js', () => ({
|
||||||
|
validateOrigin: (o: string) => o,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../utils/federationPeerActivation.js', () => ({
|
||||||
|
onPeerActivated: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { ensurePeered } = await import('./federationPeering.js');
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
const result = await ensurePeered('https://remote.example');
|
||||||
|
|
||||||
|
expect(result.status).toBe('rejected');
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
expect(result.error).toContain('needs_attention');
|
||||||
|
}
|
||||||
|
// performHandshake must not have fired — no POST to /peer/accept
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export async function ensurePeered(origin: string): Promise<EnsurePeeredResult>
|
|||||||
// Unreachable peers were previously active — treat as active for peering
|
// Unreachable peers were previously active — treat as active for peering
|
||||||
// (the health check will restore them; don't re-handshake)
|
// (the health check will restore them; don't re-handshake)
|
||||||
return { status: 'active', peerId: existing.id };
|
return { status: 'active', peerId: existing.id };
|
||||||
|
case 'needs_attention':
|
||||||
|
// Admin intervention required — do not auto-heal via performHandshake
|
||||||
|
return { status: 'rejected', error: 'Peer in needs_attention — admin Reset required' };
|
||||||
case 'awaiting_approval':
|
case 'awaiting_approval':
|
||||||
return { status: 'pending', error: 'Awaiting admin approval on remote instance' };
|
return { status: 'pending', error: 'Awaiting admin approval on remote instance' };
|
||||||
case 'pending':
|
case 'pending':
|
||||||
|
|||||||
Reference in New Issue
Block a user