Files
bookorbit/server/test/server-connection-identity.e2e-spec.ts
T
neon fbaf0b63df feat(server): publish a stable installation identity for client connections
The iOS app lets an account reach one server through several addresses, such as a home network
IP and a public name. Before it sends credentials to an alternative address it needs to know the
address leads to the same installation, so expose a public, credential-free GET /server-identity
returning a persisted UUID, and add the same optional instanceId to app-info with the
server-connection-identity feature flag.

The UUID lives in a singleton server_identity row shared by every replica, created on first use
and cached per process. An unreadable identity omits instanceId instead of failing app-info,
which clients rely on for feature detection. Existing routes and bodies are unchanged and the
web client does not call the new route; the iOS app does.

The migration uses IF NOT EXISTS because an unreleased build created the same table as 0109.
2026-10-13 19:37:52 -06:00

44 lines
2.2 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { serverIdentity } from '../src/db/schema';
import { ServerIdentityRepository } from '../src/modules/server-identity/server-identity.repository';
import { ServerIdentityService } from '../src/modules/server-identity/server-identity.service';
import { closeE2EContext, createE2EContext, type E2EContext } from './e2e/app-harness';
describe('Server connection identity', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext({ isolateAppData: true });
}, 60_000);
afterAll(async () => {
if (ctx) await closeE2EContext(ctx);
});
it('exposes a small public no-store response and the same identity through app-info', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/server-identity' });
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
const identity = response.json();
expect(identity).toEqual({ product: 'bookorbit', protocolVersion: 1, instanceId: expect.any(String) });
expect(identity.instanceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const info = await ctx.app.inject({ method: 'GET', url: '/api/v1/app-info', headers: { authorization: `Bearer ${ctx.adminToken}` } });
expect(info.statusCode).toBe(200);
expect(info.json().instanceId).toBe(identity.instanceId);
expect(info.json().features).toContain('server-connection-identity');
});
it('concurrent replicas converge on the persisted singleton and restart keeps it', async () => {
const repositories = Array.from({ length: 12 }, () => new ServerIdentityRepository(ctx.db));
const identities = await Promise.all(repositories.map((repository) => repository.getOrCreate()));
expect(new Set(identities).size).toBe(1);
expect(identities[0]).toBeTruthy();
const rows = await ctx.db.select().from(serverIdentity).where(eq(serverIdentity.id, 1));
expect(rows).toHaveLength(1);
expect(rows[0].instanceId).toBe(identities[0]);
const restarted = new ServerIdentityService(new ServerIdentityRepository(ctx.db));
expect((await restarted.getIdentity()).instanceId).toBe(identities[0]);
});
});