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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user