134 lines
6.0 KiB
TypeScript
134 lines
6.0 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { AccessKeyView, Card, Group } from '@cardy/shared';
|
|
import { SUPERADMIN, multipart, testApp } from './helpers.js';
|
|
|
|
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
|
|
|
|
async function setup() {
|
|
const { app } = await testApp();
|
|
const key = (
|
|
await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: adminHeaders, payload: {} })
|
|
).json() as AccessKeyView;
|
|
return { app, key: key.key, keyId: key.id };
|
|
}
|
|
|
|
function client(app: FastifyInstance, key: string) {
|
|
return (method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, parts?: Parameters<typeof multipart>[0]) => {
|
|
const body = parts ? multipart(parts) : undefined;
|
|
return app.inject({
|
|
method,
|
|
url,
|
|
headers: { authorization: `Bearer ${key}`, ...body?.headers },
|
|
...(body ? { payload: body.payload } : {}),
|
|
});
|
|
};
|
|
}
|
|
|
|
const cardParts = (overrides: Record<string, string> = {}) =>
|
|
Object.entries({ name: 'Shop', color: '#123456', code: '4600000000000', codeType: 'EAN13', ...overrides }).map(
|
|
([name, value]) => ({ name, value }),
|
|
);
|
|
|
|
describe('client API', () => {
|
|
it('authenticates by bearer key', async () => {
|
|
const { app, key } = await setup();
|
|
const ok = await client(app, key)('GET', '/auth');
|
|
expect(ok.statusCode).toBe(200);
|
|
expect(ok.json()).toMatchObject({ email: SUPERADMIN, group: { name: 'default' } });
|
|
expect((await client(app, 'wrong')('GET', '/auth')).statusCode).toBe(401);
|
|
expect((await app.inject({ url: '/cards' })).statusCode).toBe(401);
|
|
});
|
|
|
|
it('stops accepting a key right after it is deleted', async () => {
|
|
const { app, key, keyId } = await setup();
|
|
await app.inject({ method: 'DELETE', url: `/admin/users/${SUPERADMIN}/keys/${keyId}`, headers: adminHeaders });
|
|
expect((await client(app, key)('GET', '/auth')).statusCode).toBe(401);
|
|
});
|
|
|
|
it('answers CORS preflight without a key', async () => {
|
|
const { app } = await setup();
|
|
const res = await app.inject({
|
|
method: 'OPTIONS',
|
|
url: '/cards',
|
|
headers: { origin: 'https://localhost', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' },
|
|
});
|
|
expect(res.statusCode).toBeLessThan(300);
|
|
expect(res.headers['access-control-allow-origin']).toBe('https://localhost');
|
|
});
|
|
|
|
it('creates, edits and deletes cards with logo and images', async () => {
|
|
const { app, key } = await setup();
|
|
const api = client(app, key);
|
|
|
|
const createdRes = await api('POST', '/cards', [
|
|
...cardParts(),
|
|
{ name: 'logo', filename: 'logo.webp', mime: 'image/webp', data: Buffer.from('logo') },
|
|
]);
|
|
expect(createdRes.statusCode).toBe(201);
|
|
const created = createdRes.json() as Card;
|
|
expect(created.id).toBeTruthy();
|
|
expect(created.logo).not.toBeNull();
|
|
expect(created.imagesVersion).toBe(1);
|
|
|
|
expect((await api('POST', '/cards', cardParts({ codeType: 'BAD' }))).statusCode).toBe(400);
|
|
|
|
const edited = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Shop 2' }])).json() as Card;
|
|
expect(edited).toMatchObject({ name: 'Shop 2', code: '4600000000000', imagesVersion: 1 });
|
|
|
|
const noLogo = (await api('PUT', `/cards/${created.id}`, [{ name: 'removeLogo', value: 'true' }])).json() as Card;
|
|
expect(noLogo.logo).toBeNull();
|
|
expect(noLogo.imagesVersion).toBe(2);
|
|
|
|
const withImages = (
|
|
await api('POST', `/cards/${created.id}/images`, [
|
|
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
|
|
{ name: 'file', filename: 'b.webp', mime: 'image/webp', data: Buffer.from('b') },
|
|
])
|
|
).json() as Card;
|
|
expect(withImages.images).toHaveLength(2);
|
|
expect(withImages.imagesVersion).toBe(3);
|
|
|
|
const image = await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`);
|
|
expect(image.body).toBe('b');
|
|
|
|
const afterRemove = (await api('DELETE', `/cards/${created.id}/images/${withImages.images[0]!.id}`)).json() as Card;
|
|
expect(afterRemove.images.map((i) => i.id)).toEqual([withImages.images[1]!.id]);
|
|
|
|
expect((await api('GET', '/cards')).json()).toHaveLength(1);
|
|
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(204);
|
|
|
|
// Deleted cards are invisible to the client API but kept for admins.
|
|
expect((await api('GET', '/cards')).json()).toEqual([]);
|
|
expect((await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`)).statusCode).toBe(404);
|
|
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'x' }])).statusCode).toBe(404);
|
|
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(404);
|
|
const adminCards = (await app.inject({ url: '/admin/cards', headers: adminHeaders })).json() as Card[];
|
|
expect(adminCards[0]).toMatchObject({ id: created.id, deleted: true });
|
|
});
|
|
|
|
it('scopes cards to the key owner group', async () => {
|
|
const { app, key } = await setup();
|
|
const other = (
|
|
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
|
|
).json() as Group;
|
|
const otherHeaders = { ...adminHeaders, cookie: `cardy_group=${other.id}` };
|
|
await app.inject({ method: 'POST', url: '/admin/users', headers: otherHeaders, payload: { email: 'o@example.com' } });
|
|
const otherKey = (
|
|
await app.inject({ method: 'POST', url: '/admin/users/o@example.com/keys', headers: otherHeaders, payload: {} })
|
|
).json() as AccessKeyView;
|
|
|
|
const mine = (await client(app, key)('POST', '/cards', cardParts())).json() as Card;
|
|
const theirs = client(app, otherKey.key);
|
|
expect((await theirs('GET', '/cards')).json()).toEqual([]);
|
|
expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns JSON 404 for unknown API routes', async () => {
|
|
const { app, key } = await setup();
|
|
const res = await client(app, key)('GET', '/nope');
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.json()).toEqual({ error: 'Not found' });
|
|
});
|
|
});
|