Files

225 lines
10 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, ApiAuthResponse, 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('stores a multiline comment and keeps it on partial updates', async () => {
const { app, key } = await setup();
const api = client(app, key);
const comment = 'Line one\nLine two\n\n indented';
const created = (await api('POST', '/cards', [...cardParts(), { name: 'comment', value: ' Line one\r\nLine two\r\n\r\n indented\n' }])).json() as Card;
expect(created.comment).toBe(comment);
const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card;
expect(renamed.comment).toBe(comment);
const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: '' }])).json() as Card;
expect(cleared.comment).toBe('');
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: 'x'.repeat(2001) }])).statusCode).toBe(400);
const withoutComment = (await api('POST', '/cards', cardParts())).json() as Card;
expect(withoutComment.comment).toBe('');
const patched = await app.inject({
method: 'PATCH',
url: `/admin/cards/${created.id}`,
headers: adminHeaders,
payload: { comment: 'From admin\nsecond line' },
});
expect((patched.json() as Card).comment).toBe('From admin\nsecond line');
});
it('parses comma-separated tags and keeps them on partial updates', async () => {
const { app, key } = await setup();
const api = client(app, key);
const created = (await api('POST', '/cards', [...cardParts(), { name: 'tags', value: 'один,,, два' }])).json() as Card;
expect(created.tags).toEqual(['один', 'два']);
const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card;
expect(renamed.tags).toEqual(['один', 'два']);
const retagged = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: ' food , ,pharmacy ' }])).json() as Card;
expect(retagged.tags).toEqual(['food', 'pharmacy']);
const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'tags', value: '' }])).json() as Card;
expect(cleared.tags).toEqual([]);
expect((await api('POST', '/cards', cardParts())).json()).toMatchObject({ tags: [] });
const patched = await app.inject({
method: 'PATCH',
url: `/admin/cards/${created.id}`,
headers: adminHeaders,
payload: { tags: [' a ', '', 'b, c'] },
});
expect((patched.json() as Card).tags).toEqual(['a', 'b', 'c']);
});
it('serves each of the owner\'s groups and keeps /cards on the first one', async () => {
const { app, key } = await setup();
const api = client(app, key);
const other = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
).json() as Group;
const stranger = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Stranger' } })
).json() as Group;
await app.inject({
method: 'POST',
url: '/admin/users',
headers: { ...adminHeaders, cookie: `cardy_group=${other.id}` },
payload: { email: SUPERADMIN },
});
const auth = (await api('GET', '/auth')).json() as ApiAuthResponse;
expect(auth.groups.map((g) => g.name)).toEqual(['default', 'Other']);
expect(auth.group.name).toBe('default');
const inOther = (await api('POST', `/groups/${other.id}/cards`, cardParts({ name: 'In other' }))).json() as Card;
const inDefault = (await api('POST', `/groups/${auth.groups[0]!.id}/cards`, cardParts({ name: 'In default' }))).json() as Card;
expect(((await api('GET', `/groups/${other.id}/cards`)).json() as Card[]).map((c) => c.name)).toEqual(['In other']);
// The group-less routes (apps from before multi-group) use the first group.
expect(((await api('GET', '/cards')).json() as Card[]).map((c) => c.name)).toEqual(['In default']);
// Cards are addressed within their group only.
expect((await api('DELETE', `/groups/${other.id}/cards/${inDefault.id}`)).statusCode).toBe(404);
expect((await api('PUT', `/groups/${other.id}/cards/${inOther.id}`, [{ name: 'name', value: 'Renamed' }])).json()).toMatchObject({
name: 'Renamed',
});
// Groups the owner is not a member of do not exist for the key.
expect((await api('GET', `/groups/${stranger.id}/cards`)).statusCode).toBe(404);
expect((await api('POST', `/groups/${stranger.id}/cards`, cardParts())).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' });
});
});