server be & fe

This commit is contained in:
azykov@mail.ru
2026-09-29 13:50:01 +03:00
commit 657009bd6c
56 changed files with 6162 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
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.group };
}
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, role: 'superadmin', group: { 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('ignores the group cookie for non-superadmins', 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}`);
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);
});
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
const { app, config, root, defaultGroup } = await setup();
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);
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
});
});
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(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}`, { role: 'admin' })).status).toBe(400);
});
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<typeof multipart>[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('<script>') },
])
).json() as Card;
expect(withImages.images).toHaveLength(2);
expect(withImages.imagesVersion).toBe(2);
const html = await root('GET', `/cards/${card.id}/images/${withImages.images[1]!.id}`);
expect(html.res.headers['content-type']).toBe('application/octet-stream');
const removed = (await root('DELETE', `/cards/${card.id}/images/${withImages.images[0]!.id}`)).body as Card;
expect(removed.images).toHaveLength(1);
expect(removed.imagesVersion).toBe(3);
const noLogo = (await root('DELETE', `/cards/${card.id}/logo`)).body as Card;
expect(noLogo.logo).toBeNull();
expect(noLogo.imagesVersion).toBe(4);
expect((await root('GET', `/cards/${card.id}/logo`)).status).toBe(404);
});
it('isolates cards between groups', async () => {
const { app, root } = await setup();
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect((await inOther('GET', '/cards')).body).toEqual([]);
expect((await inOther('PATCH', `/cards/${card.id}`, { name: 'hack' })).status).toBe(404);
});
});