The owner installs, but a server may already have members, and a member added next month needs the same work. So the unit is (service × member) reachable from two triggers — install a service, provision existing members; add a member, provision installed services — rather than a loop inside the installer. Only handling the first works on day one and rots. No new table. A member is provisioned exactly when they hold a service_connections row: their own credential, url NULL, inheriting the instance from the owner's. That schema anticipated this before this existed, and a second record of the same fact would only be able to disagree with the first. Three outcomes, declared per catalogue entry so the installer never special-cases a service. `accounts` is fully transparent. `none` is a single-tenant daemon with nothing to do — filtered before the provisioning loop so callers can tell "nothing to do" from "did nothing", which look identical at a call site and matter when someone is asking why a member cannot see a feature. `invite` is not a weaker `accounts`, it is the correct outcome: Vaultwarden derives its encryption key from the master password, so a credential we could mint would mean a vault we could read. Transparent right up to where being transparent would be a defect. The per-service work is an interface implemented beside each sidecar rather than a switch in core — a central function growing a case per service is what would stop any of this shipping from its own repository. Implementations must be idempotent, since both triggers can fire for the same pair and a duplicate account upstream is not ours to undo. Deprovision is optional and defaults to leaving the upstream account alone: deleting an Immich user deletes their photos. Written assuming the vault's multi-user adaptation has landed. Today /api/vault is owner-only by an explicit ownerGate, so a member is refused before Vaultwarden is reached — verified, and out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 lines
1.1 KiB
TypeScript
25 lines
1.1 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import { plannedOutcome, servicesNeedingMemberProvision } from './members';
|
|
import { byId } from './catalogue';
|
|
|
|
describe('what adding a member would do', () => {
|
|
it('predicts the outcome per service without contacting anything', () => {
|
|
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
|
|
expect(plannedOutcome(byId('vault')!)).toBe('invited');
|
|
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
|
|
});
|
|
});
|
|
|
|
describe('which installed services have work to do', () => {
|
|
it('skips single-tenant daemons entirely', () => {
|
|
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id);
|
|
expect(ids.sort()).toEqual(['photos', 'vault']);
|
|
});
|
|
|
|
it('ignores services that are not installed', () => {
|
|
// The catalogue is what COULD be installed; only what IS installed has a member to provision.
|
|
expect(servicesNeedingMemberProvision([])).toEqual([]);
|
|
expect(servicesNeedingMemberProvision(['jellyfin']).map((e) => e.id)).toEqual(['jellyfin']);
|
|
});
|
|
});
|