feat(social): federated branch — authority + self-friend + idempotency
Adds direction-aware idempotency and already-friends checks to handleFederatedFriendRequest (after stub hydration, before transaction), plus 6 tests covering authority defense, bare-host normalization, cannot_friend_self, already_friends, same-direction idempotent 200, and opposite-direction 409.
This commit is contained in:
@@ -293,3 +293,161 @@ describe('POST /api/social/requests — federated branch (lookup failures)', ()
|
||||
expect(JSON.parse(res.body).error).toBe('invalid_target_domain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/social/requests — federated branch (authority + self-friend + idempotency)', () => {
|
||||
beforeEach(() => {
|
||||
resolveOriginFromHostnameMock.mockReturnValue('https://orbit.test');
|
||||
ensurePeeredMock.mockResolvedValue({ status: 'active', peerId: 'p1' });
|
||||
lookupRemoteUserMock.mockResolvedValue({
|
||||
ok: true, homeUserId: 'remote-alice', username: 'alice',
|
||||
profile: { displayName: 'Alice', avatar: null, avatarColor: 'mint', banner: null, bio: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 not_authoritative_for_sender when caller is a federated user', async () => {
|
||||
seedSelf({ homeInstance: 'other.test', homeUserId: 'me-elsewhere' });
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'alice@orbit.test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body).error).toBe('not_authoritative_for_sender');
|
||||
});
|
||||
|
||||
it('passes authority check when sender homeInstance is stored as bare host', async () => {
|
||||
// homeInstance stored without scheme — normalizeOriginForCompare('home.test') must
|
||||
// equal normalizeOriginForCompare('https://home.test') (T1 invariant).
|
||||
seedSelf({ homeInstance: 'home.test', homeUserId: CALLER_ID });
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'alice@orbit.test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it('returns 400 cannot_friend_self when looked-up user is canonical-self', async () => {
|
||||
// The dispatcher routes this as federated because targetDomain ('otherhost') is not
|
||||
// normalized to our host. resolveOriginFromHostname maps it to our own origin anyway
|
||||
// (defense-in-depth: misconfigured or spoofed domain). The lookup returns our own
|
||||
// canonical userId, triggering the self-friend check.
|
||||
seedSelf();
|
||||
resolveOriginFromHostnameMock.mockReturnValue('https://home.test');
|
||||
lookupRemoteUserMock.mockResolvedValue({
|
||||
ok: true, homeUserId: CALLER_ID, username: 'caller',
|
||||
profile: { displayName: 'Caller', avatar: null, avatarColor: 'mint', banner: null, bio: null },
|
||||
});
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'caller@otherhost' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toBe('cannot_friend_self');
|
||||
});
|
||||
|
||||
it('returns 409 already_friends if friendship row exists', async () => {
|
||||
seedSelf();
|
||||
// Pre-seed stub and friendship
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'stub-alice',
|
||||
username: 'remote-alice@orbit.test',
|
||||
displayName: 'Alice',
|
||||
passwordHash: '',
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
homeInstance: 'orbit.test',
|
||||
homeUserId: 'remote-alice',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
testDb.insert(schema.friends).values({
|
||||
userId: CALLER_ID,
|
||||
friendId: 'stub-alice',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'alice@orbit.test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(JSON.parse(res.body).error).toBe('already_friends');
|
||||
});
|
||||
|
||||
it('returns 200 + existing requestId when same-direction request already pending (idempotent)', async () => {
|
||||
seedSelf();
|
||||
// Pre-seed stub and same-direction pending request
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'stub-alice',
|
||||
username: 'remote-alice@orbit.test',
|
||||
displayName: 'Alice',
|
||||
passwordHash: '',
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
homeInstance: 'orbit.test',
|
||||
homeUserId: 'remote-alice',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
testDb.insert(schema.friendRequests).values({
|
||||
id: 'existing-req',
|
||||
fromId: CALLER_ID,
|
||||
toId: 'stub-alice',
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'alice@orbit.test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as { success: boolean; requestId: string };
|
||||
expect(body.requestId).toBe('existing-req');
|
||||
|
||||
// No duplicate row created
|
||||
const allRequests = testDb.select().from(schema.friendRequests).all();
|
||||
expect(allRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 409 incoming_request_exists when opposite-direction request already pending', async () => {
|
||||
seedSelf();
|
||||
// Pre-seed stub and opposite-direction pending request
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'stub-alice',
|
||||
username: 'remote-alice@orbit.test',
|
||||
displayName: 'Alice',
|
||||
passwordHash: '',
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
homeInstance: 'orbit.test',
|
||||
homeUserId: 'remote-alice',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
testDb.insert(schema.friendRequests).values({
|
||||
id: 'incoming-req',
|
||||
fromId: 'stub-alice',
|
||||
toId: CALLER_ID,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/social/requests',
|
||||
payload: { username: 'alice@orbit.test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body) as { error: string; requestId: string };
|
||||
expect(body.error).toBe('incoming_request_exists');
|
||||
expect(body.requestId).toBe('incoming-req');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,6 +217,39 @@ async function handleFederatedFriendRequest(
|
||||
}
|
||||
const stubHydrated = hydrateReplicatedUserProfile(stub, lookup.profile, db);
|
||||
|
||||
// 5a. Direction-aware idempotency
|
||||
const existingRequest = db.select().from(schema.friendRequests).where(and(
|
||||
or(
|
||||
and(eq(schema.friendRequests.fromId, sender.id), eq(schema.friendRequests.toId, stubHydrated.id)),
|
||||
and(eq(schema.friendRequests.fromId, stubHydrated.id), eq(schema.friendRequests.toId, sender.id)),
|
||||
),
|
||||
eq(schema.friendRequests.status, 'pending'),
|
||||
)).get();
|
||||
|
||||
if (existingRequest) {
|
||||
if (existingRequest.fromId === sender.id) {
|
||||
// Same direction — idempotent return
|
||||
return reply.code(200).send({ success: true, requestId: existingRequest.id });
|
||||
} else {
|
||||
// Opposite direction — incoming request already exists
|
||||
return reply.code(409).send({
|
||||
error: 'incoming_request_exists',
|
||||
statusCode: 409,
|
||||
requestId: existingRequest.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Already-friends check
|
||||
const existingFriend = db.select().from(schema.friends).where(or(
|
||||
and(eq(schema.friends.userId, sender.id), eq(schema.friends.friendId, stubHydrated.id)),
|
||||
and(eq(schema.friends.userId, stubHydrated.id), eq(schema.friends.friendId, sender.id)),
|
||||
)).get();
|
||||
|
||||
if (existingFriend) {
|
||||
return reply.code(409).send({ error: 'already_friends', statusCode: 409 });
|
||||
}
|
||||
|
||||
// 6. Transaction: insert + log + queue outbox
|
||||
const now = Date.now();
|
||||
const fromIdentity = {
|
||||
|
||||
Reference in New Issue
Block a user