fix(federation): /peer/initiate handles needs_attention/awaiting_approval/rejected rows instead of 500ing
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user