mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fbaf0b63df
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.
44 lines
2.2 KiB
TypeScript
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]);
|
|
});
|
|
});
|