test(federation): pin registration 409 + suffixed stub creation against detached accounts (detach spec §4.3.5)

Also add the positive companion assertion (folded in from a Task 3 review
Minor): findFederatedUser tier-2 STILL returns a NON-detached (orphaned=0)
same-name federated row, locking that the eq(federation_home_orphaned, 0)
clause discriminates on the flag alone and never over-filters legitimate
replicated identities.

All three behaviors pass against the shipped Task 1-3 code; no product-code
change was required.
This commit is contained in:
Jannis Braun
2026-07-02 18:47:29 +02:00
parent 68be2e26b1
commit ea66ec5dbd
2 changed files with 128 additions and 0 deletions
@@ -285,4 +285,26 @@ describe('findFederatedUser — detached (home-orphaned) accounts', () => {
const found = findFederatedUser('old-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(found?.federationHomeOrphaned).toBe(1);
});
it('tier-2 STILL matches a NON-detached same-name federated account (the exclusion clause does not over-filter)', async () => {
const { findFederatedUser } = await import('./federation.js');
// Positive companion to the exclusion test: replace the detached seed with an
// otherwise-identical NON-detached row (federationHomeOrphaned = 0). Same domain,
// same handle base, fresh homeUserId, same hint — the ONLY difference is the flag.
// This locks that the `eq(federationHomeOrphaned, 0)` clause discriminates on the
// flag alone and never withholds a legitimate replicated identity from tier-2.
testDb.delete(schema.users).where(eq(schema.users.id, 'detached-1')).run();
seedUser({
id: 'live-1',
username: 'alice@peer.example',
homeInstance: 'peer.example',
homeUserId: 'legacy-home-uid',
passwordHash: '$2b$10$abcdefghijklmnopqrstuv',
federationHomeOrphaned: 0,
});
// Fresh homeUserId → tier-1 miss; tier-2 must return the non-detached row.
const found = findFederatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(found?.id).toBe('live-1');
expect(found?.federationHomeOrphaned).toBe(0);
});
});
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
@@ -6,6 +7,12 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { setWorkerId } from './snowflake.js';
// resolveOrCreateReplicatedUser (tested below) mints a snowflake for the new
// stub row; the register route mints one for fresh accounts. Both throw if the
// worker id is never initialised. Set it once at module load.
setWorkerId(3);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
@@ -159,3 +166,102 @@ describe('backfillStubUsernamesForPeer', () => {
expect(lookupCalls).toEqual([]); // gated on peer status
});
});
describe('detached account: registration 409 + suffixed stub creation (detach spec §4.3.5)', () => {
// A detached account is a REAL federated user (homeInstance set, real bcrypt-style
// hash — NOT the '!federation-replicated' sentinel) whose home domain was reset
// and which has therefore been detached (federationHomeOrphaned = 1). Task 3's
// tier-2 exclusion makes it un-matchable via the domain+username heuristic, so a
// new same-name identity on the reset domain can neither register OVER the account
// (§4.3.5 → username-uniqueness 409) nor be BOUND to it by relay stub resolution
// (§4.3.4 → the collision guard suffixes a fresh stub instead).
const detachedId = 'detached-1';
const detachedUsername = 'alice@peer.example';
const originalHash = '$2b$10$abcdefghijklmnopqrstuv'; // real bcrypt-like hash, not a stub
function seedDetachedAccount(): void {
testDb.insert(schema.users).values({
id: detachedId,
username: detachedUsername,
displayName: null,
passwordHash: originalHash,
status: 'offline',
isAdmin: 0,
homeInstance: 'peer.example',
homeUserId: 'old-home-uid',
federationHomeOrphaned: 1,
createdAt: Date.now(),
}).run();
}
function seedFederatedRegistrationOpen(): void {
// applyMigrations creates instance_settings but does not seed the id=1 row
// (production does so via migrate.ts:ensureDefaults). The register route reads
// federatedRegistrationOpen from it; without the row the federated path 403s.
testDb.insert(schema.instanceSettings).values({
id: 1,
registrationOpen: 1,
federatedRegistrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildAuthApp(): Promise<FastifyInstance> {
const { authRoutes } = await import('../routes/auth.js');
const app = Fastify({ logger: false });
await app.register(authRoutes);
await app.ready();
return app;
}
it('federated registration of a same-name user on the reset domain returns 409, detached row untouched', async () => {
seedDetachedAccount();
seedFederatedRegistrationOpen();
const app = await buildAuthApp();
try {
// Fresh homeUserId + the reset domain replaying 'alice' as the handle. Tier-2
// no longer matches the detached row → the stub-upgrade branch is skipped →
// the plain username-uniqueness check on 'alice@peer.example' fires → 409.
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
username: 'alice@peer.example',
password: 'password123',
homeInstance: 'peer.example',
homeUserId: 'new-home-uid',
},
});
expect(res.statusCode).toBe(409);
} finally {
await app.close();
}
const row = testDb.select().from(schema.users).where(eq(schema.users.id, detachedId)).get();
expect(row?.homeUserId).toBe('old-home-uid'); // no backfill
expect(row?.passwordHash).toBe(originalHash); // no credential upgrade/re-hash
expect(row?.username).toBe(detachedUsername); // handle not rebound
expect(row?.federationHomeOrphaned).toBe(1); // still sovereign
});
it('relay stub resolution creates a SUFFIXED stub instead of binding to the detached row', async () => {
seedDetachedAccount();
const { resolveOrCreateReplicatedUser } = await import('../routes/federation.js');
// Fresh homeUserId → tier-1 miss; detached row is tier-2-excluded → no match.
// The collision guard finds 'alice@peer.example' already taken and suffixes.
const stub = resolveOrCreateReplicatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(stub).not.toBeNull();
expect(stub!.id).not.toBe(detachedId);
expect(stub!.username).not.toBe(detachedUsername);
expect(stub!.passwordHash).toBe('!federation-replicated');
expect(stub!.homeUserId).toBe('new-home-uid');
// The detached account is left entirely untouched.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, detachedId)).get();
expect(row?.username).toBe(detachedUsername);
expect(row?.homeUserId).toBe('old-home-uid');
expect(row?.passwordHash).toBe(originalHash);
expect(row?.federationHomeOrphaned).toBe(1);
});
});