Photos had no user provisioning at all: one registry row, the owner's, and every other account resolving to nothing. Same shape as the Jellyfin gap, but this one was never built rather than built and dropped. photos_accounts records who on Officer is who on Immich, and carries that person's own API key. getConfig falls back to it when the caller has no registry of their own, and there is no path from there to the owner's key — an Immich key with permissions:['all'] can read and delete the whole library, so a member resolving to it would be administering everyone's photographs rather than reading their own. Matching is on email, which Immich enforces uniqueness on and Officer already holds. That is exact where the Jellyfin side has to go through an uppercased username index and cope with two Officer names colliding into one. Two deliberate differences from the Jellyfin flow: The admin's own account is reported as itself, not as "no Officer account". That bug shipped there and was fixed in 591c0da; there was no reason to write it twice, so reconcile takes the key's identity from /api/users/me and the test pins it. And the temporary password is HANDED OVER rather than discarded. Jellyfin discards because it has no must-change-password flag, so anything handed over stays valid forever. Immich has one, and Immich's own app is where phone backup runs — discarding would leave a photo account that can never back a phone up again. So: set a password, spend it on a key, raise shouldChangePassword, return it once. POST /_config/:id/handover re-issues on demand, because "setting up my phone" happens long after provisioning and more than once. Verified against the running 3.1.0 image that an API key survives a password change, so re-issuing never costs Officer its access. Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all four keyed and each resolving to their own Immich identity. jrg.conceicao left alone. Members still need the photos grant on the Member role before the permission gate lets them through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
5.0 KiB
TypeScript
125 lines
5.0 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
||
import { normalize, planReconcile, type ImmichUser } from './reconcile';
|
||
import type { ProvisionableUser } from 'officerdb';
|
||
|
||
const user = (id: number, username: string, email: string): ProvisionableUser =>
|
||
({ id, username, email, name: null, role: 'Member' }) as ProvisionableUser;
|
||
|
||
// The real populations on alpha as of 2026-08-21, read from `GET /api/admin/users` and from
|
||
// `listProvisionableUsers()`. Real names rather than invented ones because every interesting case is
|
||
// already present here, and because a test built on the real overlap fails when the real overlap changes.
|
||
const PLATFORM = [
|
||
user(24, 'conceiba', 'conceiba@gmail.com'),
|
||
user(25, 'guipinto', 'tatudoemcampo@gmail.com'),
|
||
user(26, 'fedra', 'fedrasantamaria@gmail.com'),
|
||
user(27, 'diogo', 'dingoshf@gmail.com'),
|
||
];
|
||
|
||
const ADMIN_ID = '0e0b05c7';
|
||
|
||
const IMMICH: ImmichUser[] = [
|
||
{ id: ADMIN_ID, email: 'pastilhas@officer.dev', name: 'André Padez' }, // the connection's own account
|
||
{ id: 'aaaa1111', email: 'conceiba@gmail.com', name: 'Bruno Conceição' },
|
||
{ id: 'bbbb2222', email: 'fedrasantamaria@gmail.com', name: 'Fedra' },
|
||
{ id: 'cccc3333', email: 'jrg.conceicao@gmail.com', name: 'Jorge Conceição' }, // no Officer account
|
||
];
|
||
|
||
describe('the live instance, first run', () => {
|
||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing: [], adminImmichUserId: ADMIN_ID });
|
||
|
||
test('links the two whose email already exists', () => {
|
||
expect(plan.link.map((l) => l.user.email).sort()).toEqual(['conceiba@gmail.com', 'fedrasantamaria@gmail.com']);
|
||
expect(plan.link.find((l) => l.user.id === 24)!.immich.id).toBe('aaaa1111');
|
||
});
|
||
|
||
test('creates the two with no Immich account', () => {
|
||
expect(plan.create.map((u) => u.email).sort()).toEqual(['dingoshf@gmail.com', 'tatudoemcampo@gmail.com']);
|
||
});
|
||
|
||
test('leaves the account nobody on Officer owns, and never the admin', () => {
|
||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||
});
|
||
|
||
// The bug the Jellyfin plugin shipped and had to fix in 591c0da: the owner is excluded from the
|
||
// provisionable list on purpose, which made their own account read as belonging to nobody.
|
||
test('reports the admin as itself rather than as unclaimed', () => {
|
||
expect(plan.self?.email).toBe('pastilhas@officer.dev');
|
||
expect(plan.leave.map((u) => u.email)).not.toContain('pastilhas@officer.dev');
|
||
});
|
||
});
|
||
|
||
describe('a second run', () => {
|
||
const existing = [
|
||
{ userId: 24, immichUserId: 'aaaa1111' },
|
||
{ userId: 26, immichUserId: 'bbbb2222' },
|
||
];
|
||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing, adminImmichUserId: ADMIN_ID });
|
||
|
||
test('re-decides nothing that is already recorded', () => {
|
||
expect(plan.link).toHaveLength(0);
|
||
expect(plan.unchanged).toHaveLength(2);
|
||
});
|
||
|
||
test('still creates the two that are genuinely missing', () => {
|
||
expect(plan.create.map((u) => u.id).sort()).toEqual([25, 27]);
|
||
});
|
||
|
||
test('does not report a linked account as unclaimed', () => {
|
||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||
});
|
||
});
|
||
|
||
describe('matching', () => {
|
||
test('is case- and whitespace-insensitive, because Immich lowercases what it stores', () => {
|
||
const plan = planReconcile({
|
||
users: [user(30, 'shouty', ' FEDRASANTAMARIA@GMAIL.COM ')],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.create).toHaveLength(0);
|
||
expect(plan.link[0]!.immich.id).toBe('bbbb2222');
|
||
});
|
||
|
||
test('normalize agrees with what Immich does to an address', () => {
|
||
expect(normalize(' Fedra@Example.COM ')).toBe('fedra@example.com');
|
||
});
|
||
|
||
test('skips an Officer account with no email rather than inventing one', () => {
|
||
const plan = planReconcile({
|
||
users: [{ id: 31, username: 'nomail', email: '', name: null, role: 'Member' } as ProvisionableUser],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.create).toHaveLength(0);
|
||
expect(plan.link).toHaveLength(0);
|
||
});
|
||
|
||
test('two Officer accounts cannot both claim one Immich account', () => {
|
||
const plan = planReconcile({
|
||
users: [user(40, 'a', 'fedrasantamaria@gmail.com'), user(41, 'b', 'FedraSantamaria@gmail.com')],
|
||
immichUsers: IMMICH,
|
||
existing: [],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
expect(plan.link).toHaveLength(1);
|
||
// The loser is not silently created either — that would duplicate an address Immich would refuse.
|
||
expect(plan.create).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('an account deleted in Immich behind Officer’s back', () => {
|
||
const plan = planReconcile({
|
||
users: PLATFORM,
|
||
immichUsers: IMMICH,
|
||
existing: [{ userId: 25, immichUserId: 'gone9999' }],
|
||
adminImmichUserId: ADMIN_ID,
|
||
});
|
||
|
||
test('is reported as broken rather than quietly recreated', () => {
|
||
expect(plan.brokenLinks).toEqual([{ userId: 25, immichUserId: 'gone9999' }]);
|
||
expect(plan.create.map((u) => u.id)).not.toContain(25);
|
||
});
|
||
});
|