Files
jellyfin/sidecar/reconcile.test.ts
pastilhas 470d4f83c3 user provisioning, phase one: reconcile against the real server
the platform pushes the account list (users:list, declared by userProvisioning
in the manifest); this decides what to do about each one and does it.

DECIDING IS SEPARATE FROM DOING. reconcile.ts is pure and has no idea what
jellyfin is — which is why it can be tested against the real populations on
alpha without creating anything. nine tests, built on the actual overlap:
diogo and fedra already have jellyfin accounts, conceiba and guipinto do not,
rezio and peter exist only on jellyfin.

that test found a real bug before it shipped. officer's `username` was unique
CASE-SENSITIVELY, so `diogo` and `DIOGO` could both exist here while colliding
on jellyfin's uppercased NormalizedUsername index — and the first version
handed both the same jellyfin account, because it only checked links that
already existed rather than ones claimed during the pass. the platform side is
now case-insensitive too, so the collision cannot be created in the first
place; the guard stays because the constraint and the guard protect different
moments.

three outcomes, and the third is the point: LINK an account that already
exists, CREATE one that does not, LEAVE ALONE a jellyfin account with no
officer user. never delete. an absence in officer is indistinguishable from a
list that has not been told about someone yet, and a wrong deletion costs
somebody their watch history.

`adopted` carries that distinction into the mapping table. false means officer
made the account and may remove it; true means it existed first and was merely
linked.

NOBODY IS EVER TOLD THEIR PASSWORD. jellyfin has no must-change flag — verified
against its openapi, UserPolicy has 44 properties and none of them is one — so
a handed-over password stays valid forever. instead the generated one is used
once, immediately, to mint that person's own access token, and discarded. the
account exists and their officer session can already read their library.

which also makes rule 4 unskippable: the sign-in that verifies the account is
the same call that obtains the token, so there is no version of this that
"verifies later".

one failure does not stop the rest. a partially provisioned server is fixed by
running again; a run that aborts on the first error leaves everyone after them
unprovisioned and no record of who.
2026-08-20 20:14:04 +00:00

113 lines
5.0 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { planReconcile, normalize, type JellyfinUser } from './reconcile';
import type { ProvisionableUser } from 'officerdb';
const user = (id: number, username: string | null): ProvisionableUser =>
({ id, username, email: `${username ?? id}@example.invalid`, name: null, role: 'Member' });
// The real populations on alpha as of 2026-08-20, read from Jellyfin's own SQLite and from
// `listProvisionableUsers()`. Using them rather than invented names because every interesting case is
// already present, and because a test built on the real overlap fails when the real overlap changes.
const PLATFORM = [user(24, 'conceiba'), user(25, 'guipinto'), user(26, 'fedra'), user(27, 'diogo')];
const JELLYFIN: JellyfinUser[] = [
{ Id: 'C9197AD7', Name: 'pastilhas' }, // the owner — admin, never provisioned
{ Id: 'B18B7F9B', Name: 'diogo' },
{ Id: 'CC733AF9', Name: 'fedra' },
{ Id: '8186CDB7', Name: 'rezio' }, // no Officer account
{ Id: 'BB1486CC', Name: 'peter' }, // no Officer account
];
describe('the live server, first run', () => {
const plan = planReconcile({ users: PLATFORM, jellyfinUsers: JELLYFIN, existing: [] });
test('links the two that already exist', () => {
expect(plan.link.map((l) => l.user.username).sort()).toEqual(['diogo', 'fedra']);
expect(plan.link.find((l) => l.user.username === 'diogo')!.jellyfin.Id).toBe('B18B7F9B');
});
test('creates only the two that do not', () => {
expect(plan.create.map((u) => u.username).sort()).toEqual(['conceiba', 'guipinto']);
});
// The owner is absent from `users` by construction — they hold the admin account this authenticates as.
// So they surface as someone to LEAVE ALONE, which is correct: nothing should touch that account.
test('leaves the orphans and the owner alone', () => {
expect(plan.leave.map((j) => j.Name).sort()).toEqual(['pastilhas', 'peter', 'rezio']);
});
});
describe('running it twice', () => {
// Idempotency is the whole point of the mapping table: the second run must do nothing, and must not
// depend on Jellyfin's duplicate-name error to stop it.
test('is a no-op once everything is linked', () => {
const first = planReconcile({ users: PLATFORM, jellyfinUsers: JELLYFIN, existing: [] });
const existing = [
...first.link.map((l) => ({ userId: l.user.id, jellyfinUserId: l.jellyfin.Id })),
// as if the two creates had happened
{ userId: 24, jellyfinUserId: 'NEW-CONCEIBA' },
{ userId: 25, jellyfinUserId: 'NEW-GUIPINTO' },
];
const after = planReconcile({
users: PLATFORM,
jellyfinUsers: [...JELLYFIN, { Id: 'NEW-CONCEIBA', Name: 'conceiba' }, { Id: 'NEW-GUIPINTO', Name: 'guipinto' }],
existing,
});
expect(after.link).toEqual([]);
expect(after.create).toEqual([]);
expect(after.brokenLinks).toEqual([]);
expect(after.unchanged.length).toBe(4);
});
});
describe('case', () => {
// Jellyfin enforces uniqueness on an uppercased NormalizedUsername under a UNIQUE index. A raw
// comparison would decide `Diogo` needs creating, and Jellyfin would refuse the create on that index —
// an install failing on a constraint invisible from this side.
test('matches across case, because Jellyfin does', () => {
const plan = planReconcile({
users: [user(27, 'diogo')],
jellyfinUsers: [{ Id: 'B18B7F9B', Name: 'DIOGO' }],
existing: [],
});
expect(plan.create).toEqual([]);
expect(plan.link).toHaveLength(1);
});
test('normalize matches Jellyfin NormalizedUsername', () => {
expect(normalize(' Diogo ')).toBe('DIOGO');
});
});
describe('the awkward ones', () => {
// Jellyfin authenticates by name and has no email login, so an account with no username cannot be
// matched or created. Guessing one from the email's local part would claim a name nobody chose.
test('an Officer user with no username is skipped, not invented', () => {
const plan = planReconcile({ users: [user(99, null)], jellyfinUsers: [], existing: [] });
expect(plan.create).toEqual([]);
expect(plan.link).toEqual([]);
});
// Someone deleted the Jellyfin account in its own UI. Reported as broken rather than folded into
// `create`, because "a new person" and "an account vanished behind our back" are different events.
test('a link whose Jellyfin account is gone is reported as broken', () => {
const plan = planReconcile({
users: [user(27, 'diogo')],
jellyfinUsers: [],
existing: [{ userId: 27, jellyfinUserId: 'B18B7F9B' }],
});
expect(plan.brokenLinks).toEqual([{ userId: 27, jellyfinUserId: 'B18B7F9B' }]);
expect(plan.create).toEqual([]);
});
// Two Officer accounts cannot adopt one Jellyfin login. The second is left for a human.
test('a Jellyfin account already claimed is not adopted twice', () => {
const plan = planReconcile({
users: [user(27, 'diogo'), user(28, 'DIOGO')],
jellyfinUsers: [{ Id: 'B18B7F9B', Name: 'diogo' }],
existing: [],
});
expect(plan.link).toHaveLength(1);
expect(plan.create).toEqual([]);
});
});