test(federation-identity): #2 #4 #6 — all-remotes fan-out variants

This commit is contained in:
Jannis Braun
2026-05-04 00:06:44 +02:00
parent 3c0e024213
commit 22daaafb63
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js'; import { bootTwoInstances, bootHomePlusRemotes, type TwoInstanceHarness, type MultiRemoteHarness, type SpawnedInstance } from './helpers/twoInstanceHarness.js';
import { peerInstances } from './helpers/seedPeer.js'; import { peerInstances } from './helpers/seedPeer.js';
import type { TestUser } from './helpers/testUsers.js'; import type { TestUser } from './helpers/testUsers.js';
import { connectWs } from './helpers/wsListener.js'; import { connectWs } from './helpers/wsListener.js';
@@ -106,6 +106,128 @@ async function setupFullDeletionFixture(label: string): Promise<{
return { homeUser, remoteUser, observerOnRemote, spaceId, channelId, authoredMessageIds, dmChannelId }; return { homeUser, remoteUser, observerOnRemote, spaceId, channelId, authoredMessageIds, dmChannelId };
} }
/**
* Multi-remote variant of {@link setupFullDeletionFixture}. Given a pre-existing
* home user, mirrors the federated identity onto a SPECIFIC remote, joins a
* fresh space there with 2 authored messages + reactions, and a 1-on-1 DM with
* a local observer. Does NOT touch the home registry — the caller is expected
* to PUT the registry exactly once with all remote entries combined.
*
* Why not reuse `setupFullDeletionFixture`: that helper closes over the
* single-remote `harness` global and registers a fresh home user every call.
* The all-remotes fan-out tests need one home user mirrored onto N remotes.
*/
async function mirrorOnRemote(
home: SpawnedInstance,
homeUser: TestUser,
remote: SpawnedInstance,
label: string,
): Promise<{
remoteUser: TestUser;
observerOnRemote: TestUser;
spaceId: string;
channelId: string;
authoredMessageIds: string[];
dmChannelId: string;
}> {
const { registerLocal } = await import('./helpers/testUsers.js');
const federatedUsername = `${homeUser.username}@${home.domain}`;
const regRes = await fetch(`${remote.origin}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: federatedUsername,
password: homeUser.password,
displayName: homeUser.username,
homeInstance: home.domain,
homeUserId: homeUser.id,
}),
});
if (!regRes.ok) throw new Error(`mirror register failed (${remote.origin}): ${regRes.status} ${await regRes.text()}`);
const data = await regRes.json() as { user: { id: string }; token: string };
const remoteUser: TestUser = {
id: data.user.id,
username: federatedUsername,
password: homeUser.password,
token: data.token,
origin: remote.origin,
homeUserId: homeUser.id,
homeInstance: home.domain,
};
const observerOnRemote = await registerLocal(remote, `${label}_obs`);
const spaceRes = await fetch(`${remote.origin}/api/spaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${observerOnRemote.token}` },
body: JSON.stringify({ name: `${label}-space` }),
});
if (!spaceRes.ok) throw new Error(`create space failed: ${spaceRes.status} ${await spaceRes.text()}`);
const { id: spaceId } = await spaceRes.json() as { id: string };
const inviteRes = await fetch(`${remote.origin}/api/spaces/${spaceId}/invite`, {
method: 'POST',
headers: { Authorization: `Bearer ${observerOnRemote.token}` },
});
if (!inviteRes.ok) throw new Error(`invite failed: ${inviteRes.status} ${await inviteRes.text()}`);
const { inviteCode } = await inviteRes.json() as { inviteCode: string };
const joinRes = await fetch(`${remote.origin}/api/spaces/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` },
body: JSON.stringify({ inviteCode }),
});
if (!joinRes.ok) throw new Error(`join failed: ${joinRes.status} ${await joinRes.text()}`);
const detRes = await fetch(`${remote.origin}/api/spaces/${spaceId}`, {
headers: { Authorization: `Bearer ${remoteUser.token}` },
});
if (!detRes.ok) throw new Error(`get space details failed: ${detRes.status} ${await detRes.text()}`);
const det = await detRes.json() as { channels: { id: string }[] };
if (!det.channels?.length) throw new Error('space has no channels');
const channelId = det.channels[0].id;
const authoredMessageIds: string[] = [];
for (let i = 0; i < 2; i++) {
const msgRes = await fetch(`${remote.origin}/api/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` },
body: JSON.stringify({ content: `msg-${i}` }),
});
if (!msgRes.ok) throw new Error(`create message failed: ${msgRes.status} ${await msgRes.text()}`);
const m = await msgRes.json() as { id: string };
authoredMessageIds.push(m.id);
}
const reactWs = await connectWs(remote.origin, remoteUser.token);
try {
for (const messageId of authoredMessageIds) {
reactWs.send({ type: 'reaction_add', messageId, emoji: '👍' });
}
await new Promise(r => setTimeout(r, 300));
} finally {
reactWs.close();
}
const dmRes = await fetch(`${remote.origin}/api/dm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` },
body: JSON.stringify({ userId: observerOnRemote.id }),
});
if (!dmRes.ok) throw new Error(`create dm failed: ${dmRes.status} ${await dmRes.text()}`);
const { id: dmChannelId } = await dmRes.json() as { id: string };
const dmMsgRes = await fetch(`${remote.origin}/api/dm/${dmChannelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${remoteUser.token}` },
body: JSON.stringify({ content: 'hello' }),
});
if (!dmMsgRes.ok) throw new Error(`create dm message failed: ${dmMsgRes.status} ${await dmMsgRes.text()}`);
return { remoteUser, observerOnRemote, spaceId, channelId, authoredMessageIds, dmChannelId };
}
beforeAll(async () => { beforeAll(async () => {
harness = await bootTwoInstances(); harness = await bootTwoInstances();
sharedHmacSecret = await peerInstances(harness.home, harness.remote); sharedHmacSecret = await peerInstances(harness.home, harness.remote);
@@ -278,3 +400,249 @@ describe('Federation identity deletion — server suite', () => {
remote.close(); remote.close();
}); });
}); });
let multiHarness: MultiRemoteHarness;
describe('Federation identity deletion — all-remotes fan-out', () => {
beforeAll(async () => {
multiHarness = await bootHomePlusRemotes(2);
await peerInstances(multiHarness.home, multiHarness.remotes[0]);
await peerInstances(multiHarness.home, multiHarness.remotes[1]);
}, 90_000);
afterAll(async () => {
if (multiHarness) await multiHarness.cleanup();
});
it('#2 leave mode all-remotes: both registries cleaned, both remote rows untouched', async () => {
const { registerLocal } = await import('./helpers/testUsers.js');
const { openInspector } = await import('./helpers/dbInspect.js');
const { logMatched } = await import('./helpers/twoInstanceHarness.js');
const home = await registerLocal(multiHarness.home, 't2');
// Create federated identities on BOTH remotes for the same home user.
// Sequential awaits avoid any race on identical username inserts (the two
// remotes share neither DB nor port, but sequential is cheap and explicit).
const remoteUserIds: string[] = [];
for (const r of multiHarness.remotes) {
const resR = await fetch(`${r.origin}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: `${home.username}@${multiHarness.home.domain}`,
password: home.password,
displayName: home.username,
homeInstance: multiHarness.home.domain,
homeUserId: home.id,
}),
});
// /api/auth/register returns 201 Created (verified against auth.ts).
expect(resR.ok).toBe(true);
const data = await resR.json() as { user: { id: string }; token: string };
remoteUserIds.push(data.user.id);
}
// Seed home registry with BOTH remotes via a single PUT.
const now = Date.now();
const regRes = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-registry`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${home.token}` },
body: JSON.stringify({
updatedAt: now,
registry: multiHarness.remotes.map((r, i) => ({
origin: r.origin,
label: r.domain,
username: `${home.username}@${multiHarness.home.domain}`,
remoteUserId: remoteUserIds[i],
status: 'connected',
addedAt: now,
})),
}),
});
expect(regRes.status).toBe(200);
// Snapshot remote users pre-delete.
const beforeRows = multiHarness.remotes.map((r, i) => {
const ins = openInspector(r);
const row = ins.user(remoteUserIds[i]);
ins.close();
return row;
});
for (const before of beforeRows) {
expect(before).toBeTruthy();
expect(before!.isDeleted).toBe(0);
}
// Fan out leave to BOTH origins.
const res = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-identity/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${home.token}` },
body: JSON.stringify({ origins: multiHarness.remotes.map(r => r.origin), mode: 'leave' }),
});
expect(res.status).toBe(200);
const body = await res.json();
for (const r of multiHarness.remotes) {
expect(body.results[r.origin]).toEqual({ success: true });
}
// No S2S call should have hit either remote.
for (const r of multiHarness.remotes) {
const sawS2S = await logMatched(r, /\/api\/federation\/identity/, 1_000);
expect(sawS2S).toBe(false);
}
// Both remote user rows UNCHANGED.
multiHarness.remotes.forEach((r, i) => {
const ins = openInspector(r);
const after = ins.user(remoteUserIds[i]);
expect(after).toMatchObject({
id: beforeRows[i]!.id,
username: beforeRows[i]!.username,
isDeleted: 0,
});
ins.close();
});
// Home registry rows for BOTH origins gone.
const homeIns = openInspector(multiHarness.home);
for (const r of multiHarness.remotes) {
expect(homeIns.registryRow(home.id, r.origin)).toBeUndefined();
}
homeIns.close();
});
it('#4 soft mode all-remotes: tombstone shape on each remote, messages retained, registries cleaned', async () => {
const { registerLocal } = await import('./helpers/testUsers.js');
const { openInspector } = await import('./helpers/dbInspect.js');
const homeUser = await registerLocal(multiHarness.home, 't4');
// Mirror sequentially: parallel mirrors are safe across separate remotes,
// but sequential keeps the boot log readable and avoids any cross-remote
// contention on the home user's connection (registry PUT comes later).
const fixtures: Awaited<ReturnType<typeof mirrorOnRemote>>[] = [];
for (let i = 0; i < multiHarness.remotes.length; i++) {
fixtures.push(await mirrorOnRemote(multiHarness.home, homeUser, multiHarness.remotes[i], `t4_${i}`));
}
// Single PUT on home registry with both remote entries.
const now = Date.now();
const regRes = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-registry`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${homeUser.token}` },
body: JSON.stringify({
updatedAt: now,
registry: multiHarness.remotes.map((r, i) => ({
origin: r.origin,
label: r.domain,
username: fixtures[i].remoteUser.username,
remoteUserId: fixtures[i].remoteUser.id,
status: 'connected',
addedAt: now,
})),
}),
});
expect(regRes.status).toBe(200);
// Fan out soft delete.
const res = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-identity/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${homeUser.token}` },
body: JSON.stringify({ origins: multiHarness.remotes.map(r => r.origin), mode: 'soft' }),
});
expect(res.status).toBe(200);
const body = await res.json();
for (const r of multiHarness.remotes) {
expect(body.results[r.origin].success).toBe(true);
}
// Per-remote tombstone + content retention assertions (mirror of #3).
multiHarness.remotes.forEach((r, i) => {
const ins = openInspector(r);
const after = ins.user(fixtures[i].remoteUser.id);
expect(after).toBeTruthy();
expect(after!.isDeleted).toBe(1);
expect(after!.username).toBe(`!deleted:${fixtures[i].remoteUser.id}`);
expect(after!.passwordHash).toMatch(/^[0-9a-f]{64}$/);
expect(after!.displayName).toBeNull();
expect(after!.replicatedInstances).toBe('[]');
expect(after!.status).toBe('offline');
expect(after!.isAdmin).toBe(0);
expect(ins.spaceMembersForUser(fixtures[i].remoteUser.id)).toEqual([]);
expect(ins.messagesAuthored(fixtures[i].remoteUser.id).length).toBe(2); // RETAINED
expect(ins.reactionsForUser(fixtures[i].remoteUser.id).length).toBe(2); // RETAINED
expect(ins.dmMembership(fixtures[i].remoteUser.id)).toEqual([]);
expect(ins.dmChannelExists(fixtures[i].dmChannelId)).toBe(true);
ins.close();
});
// Home registry rows for BOTH origins gone.
const home = openInspector(multiHarness.home);
for (const r of multiHarness.remotes) {
expect(home.registryRow(homeUser.id, r.origin)).toBeUndefined();
}
home.close();
});
it('#6 full mode all-remotes: tombstone + purge on each remote, registries cleaned', async () => {
const { registerLocal } = await import('./helpers/testUsers.js');
const { openInspector } = await import('./helpers/dbInspect.js');
const homeUser = await registerLocal(multiHarness.home, 't6');
const fixtures: Awaited<ReturnType<typeof mirrorOnRemote>>[] = [];
for (let i = 0; i < multiHarness.remotes.length; i++) {
fixtures.push(await mirrorOnRemote(multiHarness.home, homeUser, multiHarness.remotes[i], `t6_${i}`));
}
const now = Date.now();
const regRes = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-registry`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${homeUser.token}` },
body: JSON.stringify({
updatedAt: now,
registry: multiHarness.remotes.map((r, i) => ({
origin: r.origin,
label: r.domain,
username: fixtures[i].remoteUser.username,
remoteUserId: fixtures[i].remoteUser.id,
status: 'connected',
addedAt: now,
})),
}),
});
expect(regRes.status).toBe(200);
const res = await fetch(`${multiHarness.home.origin}/api/users/@me/federation-identity/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${homeUser.token}` },
body: JSON.stringify({ origins: multiHarness.remotes.map(r => r.origin), mode: 'full' }),
});
expect(res.status).toBe(200);
const body = await res.json();
for (const r of multiHarness.remotes) {
expect(body.results[r.origin].success).toBe(true);
}
// Per-remote tombstone + purge assertions (mirror of #5).
multiHarness.remotes.forEach((r, i) => {
const ins = openInspector(r);
const after = ins.user(fixtures[i].remoteUser.id);
expect(after).toBeTruthy();
expect(after!.isDeleted).toBe(1);
expect(after!.username).toBe(`!deleted:${fixtures[i].remoteUser.id}`);
expect(ins.messagesAuthored(fixtures[i].remoteUser.id).length).toBe(0); // PURGED
expect(ins.reactionsForUser(fixtures[i].remoteUser.id).length).toBe(0); // PURGED
expect(ins.dmMembership(fixtures[i].remoteUser.id)).toEqual([]);
// Observer remains a member, so DM channel survives.
expect(ins.dmChannelExists(fixtures[i].dmChannelId)).toBe(true);
ins.close();
});
// Home registry rows for BOTH origins gone.
const home = openInspector(multiHarness.home);
for (const r of multiHarness.remotes) {
expect(home.registryRow(homeUser.id, r.origin)).toBeUndefined();
}
home.close();
});
});