Files
dashy/tests/server/api.test.js
2026-07-22 22:12:36 +01:00

283 lines
10 KiB
JavaScript

// @vitest-environment node
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as yaml from 'js-yaml';
// Isolate writes to a temp dir so the real user-data/conf.yml is never touched
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-api-'));
process.env.USER_DATA_DIR = tmpDir;
process.env.ENABLE_API = 'true';
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
delete process.env.ENABLE_API;
});
const app = require('../../services/app');
const seedConf = `pageInfo:
title: Test Dashboard
sections:
- name: Section One
items:
- title: Item A
url: https://example.com/a
- title: Item B
- name: Section Two
`;
const readDisk = (file) => yaml.load(fs.readFileSync(path.join(tmpDir, file), 'utf8'));
beforeEach(() => {
fs.writeFileSync(path.join(tmpDir, 'conf.yml'), seedConf);
fs.writeFileSync(path.join(tmpDir, 'page2.yml'), 'pageInfo:\n title: Page Two\n');
});
describe('API enablement gate', () => {
it('returns 404 when ENABLE_API is not true', async () => {
process.env.ENABLE_API = 'false';
const res = await request(app).get('/api/config');
expect(res.status).toBe(404);
expect(res.body.message).toContain('API not enabled');
process.env.ENABLE_API = 'true';
});
it('returns 404 when ENABLE_API is unset', async () => {
delete process.env.ENABLE_API;
const res = await request(app).get('/api/config');
expect(res.status).toBe(404);
process.env.ENABLE_API = 'true';
});
it('returns 404 for unknown API routes', async () => {
const res = await request(app).get('/api/nonsense');
expect(res.status).toBe(404);
expect(res.body.message).toBe('Not found');
});
});
describe('List and read config', () => {
it('lists config files, excluding backups', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body.files).toContain('conf.yml');
expect(res.body.files).toContain('page2.yml');
expect(res.body.files).not.toContain('config-backups');
});
it('returns a config file as JSON', async () => {
const res = await request(app).get('/api/config/conf.yml');
expect(res.status).toBe(200);
expect(res.body.pageInfo.title).toBe('Test Dashboard');
expect(res.body.sections).toHaveLength(2);
});
it('404s for a missing file', async () => {
const res = await request(app).get('/api/config/nope.yml');
expect(res.status).toBe(404);
});
it('rejects path traversal', async () => {
const res = await request(app).get('/api/config/..%2F..%2Fetc%2Fpasswd');
expect(res.status).toBe(400);
});
it('rejects non-yaml filenames', async () => {
const res = await request(app).get('/api/config/evil.txt');
expect(res.status).toBe(400);
});
it('returns a top-level key', async () => {
const res = await request(app).get('/api/config/conf.yml/pageInfo');
expect(res.status).toBe(200);
expect(res.body.title).toBe('Test Dashboard');
});
it('rejects a non-whitelisted key', async () => {
const res = await request(app).get('/api/config/conf.yml/banana');
expect(res.status).toBe(400);
});
it('404s for an absent key', async () => {
const res = await request(app).get('/api/config/conf.yml/appConfig');
expect(res.status).toBe(404);
});
it('500s for unparseable YAML', async () => {
fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed');
const res = await request(app).get('/api/config/broken.yml');
expect(res.status).toBe(500);
});
});
describe('Replace config and keys', () => {
it('replaces a whole config file and makes a backup', async () => {
const res = await request(app).put('/api/config/conf.yml')
.send({ pageInfo: { title: 'Replaced' }, sections: [] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(readDisk('conf.yml').pageInfo.title).toBe('Replaced');
const backups = fs.readdirSync(path.join(tmpDir, 'config-backups'));
expect(backups.some((f) => f.startsWith('conf-'))).toBe(true);
});
it('rejects schema-invalid conf.yml writes', async () => {
const res = await request(app).put('/api/config/conf.yml')
.send({ sections: 'not-a-list' });
expect(res.status).toBe(400);
expect(res.body.message).toContain('schema');
expect(readDisk('conf.yml').sections).toHaveLength(2);
});
it('accepts a pageInfo-only sub-page write', async () => {
const res = await request(app).put('/api/config/page2.yml')
.send({ pageInfo: { title: 'Updated Page' } });
expect(res.status).toBe(200);
expect(readDisk('page2.yml').pageInfo.title).toBe('Updated Page');
});
it('rejects oversized configs', async () => {
const res = await request(app).put('/api/config/conf.yml')
.send({ sections: [{ name: 'x'.repeat(300 * 1024) }] });
expect(res.status).toBe(400);
expect(res.body.message).toContain('256');
});
it('replaces a top-level key', async () => {
const res = await request(app).put('/api/config/conf.yml/pageInfo')
.send({ title: 'New Title' });
expect(res.status).toBe(200);
expect(readDisk('conf.yml').pageInfo.title).toBe('New Title');
});
});
describe('Sections CRUD', () => {
it('adds a section', async () => {
const res = await request(app).post('/api/config/conf.yml/sections')
.send({ name: 'Section Three' });
expect(res.status).toBe(201);
expect(res.body.index).toBe(2);
expect(readDisk('conf.yml').sections).toHaveLength(3);
});
it('rejects a section without a name', async () => {
const res = await request(app).post('/api/config/conf.yml/sections').send({});
expect(res.status).toBe(400);
});
it('gets a section by index', async () => {
const res = await request(app).get('/api/config/conf.yml/sections/0');
expect(res.body.name).toBe('Section One');
});
it('gets a section by name', async () => {
const res = await request(app).get('/api/config/conf.yml/sections/Section%20Two');
expect(res.body.name).toBe('Section Two');
});
it('patches a section, leaving other fields untouched', async () => {
const res = await request(app).patch('/api/config/conf.yml/sections/0')
.send({ icon: 'fas fa-rocket' });
expect(res.status).toBe(200);
const section = readDisk('conf.yml').sections[0];
expect(section.icon).toBe('fas fa-rocket');
expect(section.items).toHaveLength(2);
});
it('deletes a section', async () => {
const res = await request(app).delete('/api/config/conf.yml/sections/1');
expect(res.status).toBe(200);
expect(readDisk('conf.yml').sections).toHaveLength(1);
});
it('404s for an unknown section', async () => {
expect((await request(app).get('/api/config/conf.yml/sections/99')).status).toBe(404);
expect((await request(app).get('/api/config/conf.yml/sections/Nope')).status).toBe(404);
});
it('400s when sections is not a list', async () => {
fs.writeFileSync(path.join(tmpDir, 'scalar.yml'), 'sections: just-a-string\n');
const res = await request(app).get('/api/config/scalar.yml/sections/0');
expect(res.status).toBe(400);
});
});
describe('Items CRUD', () => {
it('lists items, defaulting to empty for a section without any', async () => {
expect((await request(app).get('/api/config/conf.yml/sections/0/items')).body).toHaveLength(2);
expect((await request(app).get('/api/config/conf.yml/sections/1/items')).body).toEqual([]);
});
it('adds an item, creating the items array if missing', async () => {
const res = await request(app).post('/api/config/conf.yml/sections/1/items')
.send({ title: 'New Item' });
expect(res.status).toBe(201);
expect(readDisk('conf.yml').sections[1].items[0].title).toBe('New Item');
});
it('rejects an item without a title', async () => {
const res = await request(app).post('/api/config/conf.yml/sections/0/items').send({});
expect(res.status).toBe(400);
});
it('gets an item by index and by title', async () => {
expect((await request(app).get('/api/config/conf.yml/sections/0/items/1')).body.title).toBe('Item B');
expect((await request(app).get('/api/config/conf.yml/sections/0/items/Item%20A')).body.title).toBe('Item A');
});
it('patches an item', async () => {
const res = await request(app).patch('/api/config/conf.yml/sections/0/items/0')
.send({ url: 'https://example.com/new' });
expect(res.status).toBe(200);
const item = readDisk('conf.yml').sections[0].items[0];
expect(item.url).toBe('https://example.com/new');
expect(item.title).toBe('Item A');
});
it('deletes an item', async () => {
const res = await request(app).delete('/api/config/conf.yml/sections/0/items/0');
expect(res.status).toBe(200);
expect(readDisk('conf.yml').sections[0].items).toHaveLength(1);
});
it('404s for an unknown item', async () => {
const res = await request(app).get('/api/config/conf.yml/sections/0/items/99');
expect(res.status).toBe(404);
});
});
describe('Robustness', () => {
it('returns JSON for malformed request bodies', async () => {
const res = await request(app).post('/api/config/conf.yml/sections')
.set('Content-Type', 'application/json').send('{not json');
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns JSON for bodies over the 1mb parser limit', async () => {
const res = await request(app).put('/api/config/conf.yml')
.set('Content-Type', 'application/json')
.send(`{"sections": [{"name": "${'x'.repeat(1100 * 1024)}"}]}`);
expect(res.status).toBe(413);
expect(res.body.success).toBe(false);
});
it('does not write when the existing file is unparseable', async () => {
fs.writeFileSync(path.join(tmpDir, 'broken.yml'), 'foo: [unclosed');
const res = await request(app).patch('/api/config/broken.yml/sections/0').send({ name: 'x' });
expect(res.status).toBe(500);
expect(fs.readFileSync(path.join(tmpDir, 'broken.yml'), 'utf8')).toBe('foo: [unclosed');
});
it('handles concurrent writes without corrupting the file', async () => {
const patch = (icon) => request(app)
.patch('/api/config/conf.yml/sections/0').send({ icon });
const results = await Promise.all(['a', 'b', 'c', 'd', 'e'].map(patch));
results.forEach((res) => expect(res.status).toBe(200));
expect(() => readDisk('conf.yml')).not.toThrow();
});
});