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' });
|
const found = findFederatedUser('old-home-uid', 'peer.example', testDb, { username: 'alice' });
|
||||||
expect(found?.federationHomeOrphaned).toBe(1);
|
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 { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
@@ -6,6 +7,12 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as schema from '../db/schema.js';
|
import * as schema from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
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));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
@@ -159,3 +166,102 @@ describe('backfillStubUsernamesForPeer', () => {
|
|||||||
expect(lookupCalls).toEqual([]); // gated on peer status
|
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