import { existsSync } from 'node:fs'; import path from 'node:path'; import type { FastifyInstance } from 'fastify'; import { describe, expect, it } from 'vitest'; import type { AccessKeyView, Card, Group, MeResponse, UserView } from '@cardy/shared'; import { SUPERADMIN, multipart, testApp } from './helpers.js'; type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; function admin(app: FastifyInstance, email: string, cookie?: string) { return async (method: Method, url: string, body?: unknown) => { const res = await app.inject({ method, url: `/admin${url}`, headers: { 'remote-email': email, 'x-cardy-admin': '1', ...(cookie ? { cookie } : {}) }, ...(body !== undefined ? { payload: body as object } : {}), }); const isJson = String(res.headers['content-type'] ?? '').startsWith('application/json'); return { status: res.statusCode, body: isJson ? res.json() : undefined, res }; }; } async function setup() { const { app, config } = await testApp(); const root = admin(app, SUPERADMIN); const me = (await root('GET', '/me')).body as MeResponse; return { app, config, root, defaultGroup: me.currentGroup }; } describe('admin API authentication', () => { it('bootstraps the superadmin from config', async () => { const { root } = await setup(); const { status, body } = await root('GET', '/me'); expect(status).toBe(200); expect(body).toMatchObject({ email: SUPERADMIN, superadmin: true, role: 'superadmin', groups: [{ name: 'default', role: 'admin' }], currentGroup: { name: 'default' }, }); }); it('returns 401 without header or for unknown users', async () => { const { app } = await setup(); expect((await app.inject({ url: '/admin/me' })).statusCode).toBe(401); expect((await admin(app, 'nobody@example.com')('GET', '/me')).status).toBe(401); }); it('matches Remote-Email case-insensitively', async () => { const { app } = await setup(); expect((await admin(app, SUPERADMIN.toUpperCase())('GET', '/me')).status).toBe(200); }); it('requires the CSRF header on mutations', async () => { const { app } = await setup(); const res = await app.inject({ method: 'POST', url: '/admin/groups', headers: { 'remote-email': SUPERADMIN }, payload: { name: 'x' }, }); expect(res.statusCode).toBe(403); }); }); describe('groups and current group', () => { it('lets the superadmin switch groups via cookie', async () => { const { app, root, defaultGroup } = await setup(); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; expect((await root('POST', '/groups', { name: 'other' })).status).toBe(409); const switched = await root('PUT', '/current-group', { groupId: other.id }); expect(switched.status).toBe(200); const setCookie = String(switched.res.headers['set-cookie']); expect(setCookie).toContain(`cardy_group=${other.id}`); expect(setCookie).toMatch(/HttpOnly/i); const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); expect(((await inOther('GET', '/me')).body as MeResponse).currentGroup.id).toBe(other.id); // Unknown group in cookie falls back to own group. const stale = admin(app, SUPERADMIN, 'cardy_group=missing'); expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); }); it('lets members switch only between their own groups', async () => { const { app, root, defaultGroup } = await setup(); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`); // Not a member of "Other" yet: the cookie is ignored and switching is refused. expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id); expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403); expect((await adm('GET', '/groups')).status).toBe(403); const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); expect((await inOther('POST', '/users', { email: 'adm@example.com' })).status).toBe(201); expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(200); const me = (await adm('GET', '/me')).body as MeResponse; expect(me.currentGroup.id).toBe(other.id); expect(me.role).toBe('user'); expect(me.groups.map((g) => [g.name, g.role])).toEqual([ ['default', 'admin'], ['Other', 'user'], ]); // Admin in "default" only: no admin rights in "Other". expect((await adm('GET', '/users')).status).toBe(403); }); it('refuses to delete a group that is the only group of some user', async () => { const { app, config, root, defaultGroup } = await setup(); // "default" is the superadmin's only group. expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); await inOther('POST', '/users', { email: 'u@example.com' }); await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' }); expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true); const refused = await root('DELETE', `/groups/${other.id}`); expect(refused.status).toBe(409); expect((refused.body as { error: string }).error).toContain('u@example.com'); // Once the user has another group, the group can go: its cards are removed, the user stays. await root('POST', '/users', { email: 'u@example.com' }); expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204); expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false); const me = (await admin(app, 'u@example.com')('GET', '/me')).body as MeResponse; expect(me.groups.map((g) => g.id)).toEqual([defaultGroup.id]); }); }); describe('users and keys', () => { it('enforces role rules', async () => { const { app, root } = await setup(); await root('POST', '/users', { email: 'adm@example.com', role: 'admin' }); await root('POST', '/users', { email: 'User@Example.com' }); const adm = admin(app, 'adm@example.com'); const user = admin(app, 'user@example.com'); expect((await user('GET', '/users')).status).toBe(403); expect((await user('GET', '/cards')).status).toBe(403); expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(400); expect((await adm('PATCH', '/users/user@example.com', { superadmin: true })).status).toBe(403); expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403); expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403); expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400); expect((await adm('DELETE', '/users/adm@example.com')).status).toBe(400); expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200); expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409); expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400); expect((await root('PATCH', `/users/${SUPERADMIN}`, { superadmin: false })).status).toBe(400); const promoted = await root('PATCH', '/users/user@example.com', { superadmin: true }); expect((promoted.body as UserView).superadmin).toBe(true); // Now a superadmin: plain admins can no longer manage them. expect((await adm('PATCH', '/users/user@example.com', { role: 'user' })).status).toBe(403); }); it('keeps roles per group and removes users from one group or entirely', async () => { const { app, root } = await setup(); const other = (await root('POST', '/groups', { name: 'Other' })).body as Group; const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`); await root('POST', '/users', { email: 'multi@example.com', role: 'admin' }); const added = await inOther('POST', '/users', { email: 'multi@example.com', role: 'user' }); expect(added.status).toBe(201); expect((added.body as UserView).role).toBe('user'); expect((added.body as UserView).groups.map((g) => [g.name, g.role])).toEqual([ ['default', 'admin'], ['Other', 'user'], ]); // Leaving one group keeps the user. expect((await inOther('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: false }); const me = (await admin(app, 'multi@example.com')('GET', '/me')).body as MeResponse; expect(me.groups.map((g) => g.name)).toEqual(['default']); // Removing the user from their only group deletes the user. expect((await root('DELETE', '/users/multi@example.com')).body).toEqual({ deleted: true }); expect((await admin(app, 'multi@example.com')('GET', '/me')).status).toBe(401); }); it('reads users.yaml from before multi-group', async () => { const { mkdir, writeFile } = await import('node:fs/promises'); const { testConfig } = await import('./helpers.js'); const { buildApp } = await import('../src/app.js'); const config = await testConfig(); await mkdir(config.dataDir, { recursive: true }); await writeFile( path.join(config.dataDir, 'users.yaml'), [ 'groups:', ' - { id: g1, name: Home }', 'users:', ` - { email: ${SUPERADMIN}, groupId: g1, role: superadmin, keys: [] }`, ' - { email: adm@example.com, groupId: g1, role: admin, keys: [] }', ' - { email: kid@example.com, groupId: g1, role: user, keys: [] }', ].join('\n'), ); const app = await buildApp(config); const view = (email: string) => admin(app, email)('GET', '/me').then((r) => r.body as MeResponse); expect(await view(SUPERADMIN)).toMatchObject({ superadmin: true, role: 'superadmin', groups: [{ id: 'g1', role: 'admin' }] }); expect(await view('adm@example.com')).toMatchObject({ superadmin: false, role: 'admin', groups: [{ id: 'g1', role: 'admin' }] }); expect(await view('kid@example.com')).toMatchObject({ superadmin: false, role: 'user', groups: [{ id: 'g1', role: 'user' }] }); }); it('admin generates keys; users see their own keys with deep links', async () => { const { app, root } = await setup(); await root('POST', '/users', { email: 'user@example.com' }); const created = await root('POST', '/users/user@example.com/keys', { label: 'Phone' }); expect(created.status).toBe(201); const key = created.body as AccessKeyView; expect(key.key).toMatch(/^[A-Za-z0-9]{32}$/); expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://cards.example.com/api')}&key=${key.key}`); const user = admin(app, 'user@example.com'); expect((await user('POST', '/users/user@example.com/keys', {})).status).toBe(403); expect((await user('GET', '/my-keys')).body).toEqual([key]); const listed = (await root('GET', '/users')).body as UserView[]; expect(listed.find((u) => u.email === 'user@example.com')?.keys).toEqual([key]); expect((await root('DELETE', `/users/user@example.com/keys/${key.id}`)).status).toBe(204); expect((await user('GET', '/my-keys')).body).toEqual([]); }); it('stores keys encrypted', async () => { const { app, config, root } = await setup(); const key = (await root('POST', `/users/${SUPERADMIN}/keys`, {})).body as AccessKeyView; const { readFile } = await import('node:fs/promises'); const yaml = await readFile(path.join(config.dataDir, 'users.yaml'), 'utf8'); expect(yaml).not.toContain(key.key); expect(yaml).toContain('encrypted:'); await app.close(); }); }); describe('cards', () => { it('supports CRUD, soft delete, restore and purge', async () => { const { root } = await setup(); const created = await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: '4600000000000', codeType: 'EAN13' }); expect(created.status).toBe(201); const card = created.body as Card; expect(card).toMatchObject({ name: 'Shop', imagesVersion: 0, deleted: false, logo: null, images: [] }); expect((await root('POST', '/cards', { name: 'x', color: 'red', code: '1', codeType: 'EAN13' })).status).toBe(400); expect((await root('POST', '/cards', { name: 'x', color: '#000000', code: '1', codeType: 'NOPE' })).status).toBe(400); const patched = await root('PATCH', `/cards/${card.id}`, { name: 'Shop 2' }); expect((patched.body as Card).name).toBe('Shop 2'); const deleted = await root('DELETE', `/cards/${card.id}`); expect((deleted.body as Card).deleted).toBe(true); expect(((await root('GET', '/cards')).body as Card[])[0]?.deleted).toBe(true); const restored = await root('PATCH', `/cards/${card.id}`, { deleted: false }); expect((restored.body as Card).deleted).toBe(false); await root('DELETE', `/cards/${card.id}`); expect((await root('DELETE', `/cards/${card.id}`)).status).toBe(204); expect((await root('GET', '/cards')).body).toEqual([]); }); it('manages logo and images with version bumps', async () => { const { app, root } = await setup(); const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card; const png = Buffer.from('fake-png'); const upload = (url: string, method: 'PUT' | 'POST', parts: Parameters[0]) => { const { payload, headers } = multipart(parts); return app.inject({ method, url: `/admin${url}`, payload, headers: { ...headers, 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' } }); }; const withLogo = (await upload(`/cards/${card.id}/logo`, 'PUT', [{ name: 'file', filename: 'l.png', mime: 'image/png', data: png }])).json() as Card; expect(withLogo.logo?.mime).toBe('image/png'); expect(withLogo.imagesVersion).toBe(1); const logoRes = await root('GET', `/cards/${card.id}/logo`); expect(logoRes.res.rawPayload).toEqual(png); expect(logoRes.res.headers['content-type']).toBe('image/png'); expect(logoRes.res.headers['content-security-policy']).toBe('sandbox'); const withImages = ( await upload(`/cards/${card.id}/images`, 'POST', [ { name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') }, { name: 'file', filename: 'b.html', mime: 'text/html', data: Buffer.from('