server be & fe
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AccessKeyView } from '@cardy/shared';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { SUPERADMIN, testApp } from './helpers.js';
|
||||
|
||||
const baseEnv = {
|
||||
KEY_ENCRYPTION_SECRET: randomBytes(32).toString('base64'),
|
||||
SUPERADMIN_EMAIL: 'Root@Example.com',
|
||||
};
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('applies defaults', () => {
|
||||
const config = loadConfig(baseEnv);
|
||||
expect(config).toMatchObject({ superadminEmail: 'root@example.com', superadminGroup: 'default', host: '127.0.0.1', port: 10002 });
|
||||
expect(config.publicApiUrl).toBeUndefined();
|
||||
expect(config.adminProxySecret).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates values', () => {
|
||||
expect(loadConfig({ ...baseEnv, PUBLIC_API_URL: 'https://dev.aggtaa.com/api/' }).publicApiUrl).toBe('https://dev.aggtaa.com/api');
|
||||
expect(() => loadConfig({ ...baseEnv, PUBLIC_API_URL: 'nope' })).toThrow();
|
||||
expect(() => loadConfig({ ...baseEnv, SUPERADMIN_EMAIL: '' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverse proxy integration', () => {
|
||||
const proxied = {
|
||||
'remote-email': SUPERADMIN,
|
||||
'x-cardy-admin': '1',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-host': 'dev.aggtaa.com',
|
||||
'x-forwarded-prefix': '/api',
|
||||
};
|
||||
|
||||
it('derives the deep link server and cookie path from X-Forwarded-* headers', async () => {
|
||||
const { app } = await testApp({ publicApiUrl: undefined });
|
||||
const key = (await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: proxied, payload: {} })).json() as AccessKeyView;
|
||||
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://dev.aggtaa.com/api')}&key=${key.key}`);
|
||||
|
||||
const me = await app.inject({ url: '/admin/me', headers: proxied });
|
||||
const cookie = String(me.headers['set-cookie']);
|
||||
expect(cookie).toMatch(/Path=\/api\/admin/);
|
||||
expect(cookie).toMatch(/Secure/);
|
||||
});
|
||||
|
||||
it('ignores a malformed X-Forwarded-Prefix', async () => {
|
||||
const { app } = await testApp({ publicApiUrl: undefined });
|
||||
const key = (
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/users/${SUPERADMIN}/keys`,
|
||||
headers: { ...proxied, 'x-forwarded-prefix': '/api"><script>' },
|
||||
payload: {},
|
||||
})
|
||||
).json() as AccessKeyView;
|
||||
expect(key.deepLink).toContain(`server=${encodeURIComponent('https://dev.aggtaa.com')}&key=`);
|
||||
});
|
||||
|
||||
it('rejects admin requests without the proxy secret when configured', async () => {
|
||||
const { app } = await testApp({ adminProxySecret: 's3cret' });
|
||||
const headers = { 'remote-email': SUPERADMIN };
|
||||
expect((await app.inject({ url: '/admin/me', headers })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 'wrong' } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 's3cret' } })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('does not answer CORS preflight for admin paths', async () => {
|
||||
const { app } = await testApp();
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/admin/me',
|
||||
headers: { origin: 'https://evil.example', 'access-control-request-method': 'GET' },
|
||||
});
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach } from 'vitest';
|
||||
import { buildApp } from '../src/app.js';
|
||||
import type { Config } from '../src/config.js';
|
||||
|
||||
export const SUPERADMIN = 'root@example.com';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
export async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'cardy-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
export async function testConfig(overrides: Partial<Config> = {}): Promise<Config> {
|
||||
return {
|
||||
dataDir: await tempDir(),
|
||||
keySecret: randomBytes(32),
|
||||
superadminEmail: SUPERADMIN,
|
||||
superadminGroup: 'default',
|
||||
publicApiUrl: 'https://cards.example.com/api',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testApp(overrides: Partial<Config> = {}) {
|
||||
const config = await testConfig(overrides);
|
||||
const app = await buildApp(config);
|
||||
return { app, config };
|
||||
}
|
||||
|
||||
export function multipart(parts: Array<{ name: string; value: string } | { name: string; filename: string; mime: string; data: Buffer }>) {
|
||||
const boundary = `----cardy${randomBytes(8).toString('hex')}`;
|
||||
const chunks: Buffer[] = [];
|
||||
for (const part of parts) {
|
||||
let head = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`;
|
||||
if ('filename' in part) head += `; filename="${part.filename}"\r\nContent-Type: ${part.mime}`;
|
||||
chunks.push(Buffer.from(`${head}\r\n\r\n`), 'data' in part ? part.data : Buffer.from(part.value), Buffer.from('\r\n'));
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
return { payload: Buffer.concat(chunks), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseKeySecret } from '../src/config.js';
|
||||
import { ACCESS_KEY_LENGTH, decryptSecret, encryptSecret, generateAccessKey } from '../src/crypto.js';
|
||||
import { YamlFile } from '../src/yamlFile.js';
|
||||
import { tempDir } from './helpers.js';
|
||||
|
||||
describe('crypto', () => {
|
||||
it('generates 32-char alphanumeric keys', () => {
|
||||
const keys = new Set(Array.from({ length: 100 }, generateAccessKey));
|
||||
expect(keys.size).toBe(100);
|
||||
for (const key of keys) expect(key).toMatch(new RegExp(`^[A-Za-z0-9]{${ACCESS_KEY_LENGTH}}$`));
|
||||
});
|
||||
|
||||
it('round-trips with AES-256-GCM using a random IV', () => {
|
||||
const secret = randomBytes(32);
|
||||
const a = encryptSecret('hello', secret);
|
||||
const b = encryptSecret('hello', secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(decryptSecret(a, secret)).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects a wrong secret or tampered data', () => {
|
||||
const secret = randomBytes(32);
|
||||
const encrypted = encryptSecret('hello', secret);
|
||||
expect(() => decryptSecret(encrypted, randomBytes(32))).toThrow();
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
buf[buf.length - 1]! ^= 1;
|
||||
expect(() => decryptSecret(buf.toString('base64'), secret)).toThrow();
|
||||
});
|
||||
|
||||
it('validates KEY_ENCRYPTION_SECRET', () => {
|
||||
expect(parseKeySecret(randomBytes(32).toString('base64'))).toHaveLength(32);
|
||||
expect(() => parseKeySecret(randomBytes(16).toString('base64'))).toThrow(/32 bytes/);
|
||||
expect(() => parseKeySecret(undefined)).toThrow(/required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YamlFile', () => {
|
||||
const make = (file: string) =>
|
||||
new YamlFile<{ items: number[] }>(file, (raw) => (raw as { items: number[] }) ?? { items: [] }, () => ({ items: [] }));
|
||||
|
||||
it('returns empty data for a missing file and persists updates', async () => {
|
||||
const file = path.join(await tempDir(), 'sub', 'data.yaml');
|
||||
const store = make(file);
|
||||
expect(await store.read()).toEqual({ items: [] });
|
||||
await store.update((d) => d.items.push(1));
|
||||
expect(await make(file).read()).toEqual({ items: [1] });
|
||||
});
|
||||
|
||||
it('serializes concurrent updates', async () => {
|
||||
const store = make(path.join(await tempDir(), 'data.yaml'));
|
||||
await Promise.all(Array.from({ length: 20 }, (_, i) => store.update((d) => d.items.push(i))));
|
||||
expect((await store.read()).items).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('does not write when the update throws', async () => {
|
||||
const file = path.join(await tempDir(), 'data.yaml');
|
||||
const store = make(file);
|
||||
await store.update((d) => d.items.push(1));
|
||||
await expect(
|
||||
store.update((d) => {
|
||||
d.items.push(2);
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
expect((await store.read()).items).toEqual([1]);
|
||||
expect(await readFile(file, 'utf8')).not.toContain('2');
|
||||
});
|
||||
|
||||
it('picks up manual edits', async () => {
|
||||
const file = path.join(await tempDir(), 'data.yaml');
|
||||
const store = make(file);
|
||||
await store.update((d) => d.items.push(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await writeFile(file, 'items: [7, 8]\n');
|
||||
expect((await store.read()).items).toEqual([7, 8]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user