fix(federation): /peer/initiate handles needs_attention/awaiting_approval/rejected rows instead of 500ing

This commit is contained in:
Jannis Braun
2026-07-02 13:29:46 +02:00
parent 83ebc06759
commit 6926bdb8f8
2 changed files with 123 additions and 2 deletions
@@ -297,4 +297,103 @@ describe('POST /api/federation/peer/initiate — 202 token capture & 200 clear',
expect(peer?.needsAttentionReason).toBeNull();
expect(peer?.peerInstanceId).toBe('remote-epoch');
});
// ─── Existing-row status handling — never fall through to a duplicate insert ──
// Regression guard: for a pre-existing peer row in needs_attention /
// awaiting_approval / rejected, /peer/initiate must NOT hit the UNIQUE(origin)
// constraint and 500. needs_attention/rejected delete+proceed (authenticated
// local-admin retry; heal state lives off the peer row); awaiting_approval 409s.
function seedExistingPeer(status: string, extra: Partial<typeof schema.federationPeers.$inferInsert> = {}): string {
const id = `existing-${status}`;
testDb.insert(schema.federationPeers).values({
id,
origin: 'https://remote.example',
hmacSecret: 'stale-secret-from-old-peering',
status,
createdAt: Date.now() - 60_000,
...extra,
}).run();
return id;
}
it('needs_attention row → does NOT 500; deletes the stale row and proceeds to a fresh handshake (activates)', async () => {
const oldId = seedExistingPeer('needs_attention', { needsAttentionReason: 'repeer_incomplete' });
vi.spyOn(globalThis, 'fetch').mockImplementation(makeUrlAwareFetch(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote', instanceId: 'remote-epoch' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
'remote-epoch',
));
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/initiate',
payload: { remoteOrigin: 'https://remote.example' },
});
expect(response.statusCode).not.toBe(500);
expect(response.statusCode).toBe(200);
// The stale row was replaced by a fresh handshake row (new id), now active.
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer).toBeDefined();
expect(peer?.id).not.toBe(oldId);
expect(peer?.status).toBe('active');
expect(peer?.needsAttentionReason).toBeNull();
expect(peer?.hmacSecret).toBe(PENDING_SECRET);
});
it('rejected row → does NOT 500; deletes the stale row and proceeds to a fresh handshake', async () => {
const oldId = seedExistingPeer('rejected');
vi.spyOn(globalThis, 'fetch').mockImplementation(makeUrlAwareFetch(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote', instanceId: 'remote-epoch' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
'remote-epoch',
));
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/initiate',
payload: { remoteOrigin: 'https://remote.example' },
});
expect(response.statusCode).not.toBe(500);
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.id).not.toBe(oldId);
expect(peer?.status).toBe('active');
});
it('awaiting_approval row → 409 with the awaiting-approval message; row untouched (no duplicate insert)', async () => {
const oldId = seedExistingPeer('awaiting_approval', { approvalToken: 'a'.repeat(64) });
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ accepted: true }), { status: 200 }),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/initiate',
payload: { remoteOrigin: 'https://remote.example' },
});
expect(response.statusCode).toBe(409);
expect(response.json().error).toMatch(/awaiting the remote admin's approval/i);
// No outbound handshake, no duplicate row — the existing row is untouched.
expect(fetchSpy).not.toHaveBeenCalled();
const rows = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).all();
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(oldId);
});
});
+24 -2
View File
@@ -850,8 +850,30 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
statusCode: 409,
});
}
// If revoked, allow re-initiation by removing the old record
if (existing.status === 'revoked') {
if (existing.status === 'awaiting_approval') {
return reply.code(409).send({
error: "A peering handshake with this instance is awaiting the remote admin's approval",
statusCode: 409,
});
}
// Every remaining terminal/parked state is safe to clear and re-initiate
// from — falling through here (rather than to the db.insert below) is what
// keeps this route from violating UNIQUE(origin) and 500-ing.
// - revoked: local admin revoked; re-initiate cleanly.
// - rejected: a prior attempt was rejected; allow the local admin's
// authenticated retry (mirrors revoked).
// - needs_attention: this IS the one-click Re-peer step (resetPeer +
// initiate). Deleting the row here is equivalent to the documented
// reset: the reset-heal snapshot lives on users.federation_heal_pending
// (not the peer row) and the federation_reset_events journal is designed
// to survive peer-row deletion (design §4.2/§6.1), and onPeerActivated
// after the fresh handshake re-triggers the heal — so no recovery state
// is lost by removing the local needs_attention peer row here.
if (
existing.status === 'revoked' ||
existing.status === 'rejected' ||
existing.status === 'needs_attention'
) {
db.delete(schema.federationPeers).where(eq(schema.federationPeers.id, existing.id)).run();
}
}