From ea66ec5dbd9b30d42bb13d25f762a7d8512e99af Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:47:29 +0200 Subject: [PATCH] =?UTF-8?q?test(federation):=20pin=20registration=20409=20?= =?UTF-8?q?+=20suffixed=20stub=20creation=20against=20detached=20accounts?= =?UTF-8?q?=20(detach=20spec=20=C2=A74.3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/routes/federation.userLookup.test.ts | 22 ++++ .../src/utils/federationStubBackfill.test.ts | 106 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/packages/server/src/routes/federation.userLookup.test.ts b/packages/server/src/routes/federation.userLookup.test.ts index e3872452..f69aea9c 100644 --- a/packages/server/src/routes/federation.userLookup.test.ts +++ b/packages/server/src/routes/federation.userLookup.test.ts @@ -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); + }); }); diff --git a/packages/server/src/utils/federationStubBackfill.test.ts b/packages/server/src/utils/federationStubBackfill.test.ts index 3b3fb3ef..35bbddbd 100644 --- a/packages/server/src/utils/federationStubBackfill.test.ts +++ b/packages/server/src/utils/federationStubBackfill.test.ts @@ -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>; @@ -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 { + 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); + }); +});