Files
dashcaddy/dashcaddy-api/__tests__/network-resources.routes.test.js
T

239 lines
10 KiB
JavaScript

/**
* Route tests for network-resources routes (DC-144).
* Coverage:
* - CRUD contract (GET list, GET one, POST, PATCH, DELETE)
* - validation errors: bad slug name, port out of range / non-integer,
* empty host, bad type/protocol/sensitive
* - duplicate create → 409; unknown name → 404; name immutable on PATCH
* - sensitive flag round-trip (create true → read true → flip → false)
* - defaults: displayName falls back to name, type 'app', manual true
* - services-registry seeding: skipped silently when the services file
* is missing/unreadable; seeded entries are manual:false
* Hermetic — supertest against an in-process express app over a real
* store in a temp dir, no network.
*/
'use strict';
const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createResourcesStore } = require('../src/security/network-resources');
const networkResourcesRoutes = require('../routes/network-resources');
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
function createApp({ servicesFile } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-resourceroutes-'));
const resourcesStore = createResourcesStore({ dataDir: dir, servicesFile });
const router = networkResourcesRoutes({
resourcesStore,
asyncHandler,
log: { info: () => {}, warn: () => {}, error: () => {} },
});
const app = express();
app.use(express.json());
app.use('/network/resources', router);
app.use((err, _req, res, _next) => {
const status = err.statusCode || 500;
res.status(status).json({ success: false, error: err.message, code: err.code || null });
});
return { app, dir, resourcesStore };
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
describe('network-resources routes: contract', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('GET / lists resources (empty initially)', async () => {
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
});
test('POST / creates a resource with defaults', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'plex', host: 'plex.internal', port: 32400 });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({
name: 'plex',
displayName: 'plex',
type: 'app',
host: 'plex.internal',
port: 32400,
protocol: 'http',
sensitive: false,
manual: true,
});
});
test('POST / accepts explicit displayName/type/protocol/sensitive', async () => {
const res = await request(app).post('/network/resources')
.send({ name: 'nas', displayName: 'NAS Box', type: 'device', host: 'nas.internal', port: 5000, protocol: 'https', sensitive: true });
expect(res.status).toBe(201);
expect(res.body.resource).toMatchObject({ displayName: 'NAS Box', type: 'device', protocol: 'https', sensitive: true });
});
test('GET /:name returns one resource; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const hit = await request(app).get('/network/resources/plex');
expect(hit.status).toBe(200);
expect(hit.body.resource.name).toBe('plex');
const miss = await request(app).get('/network/resources/nope');
expect(miss.status).toBe(404);
});
test('POST / duplicate name → 409', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'other', port: 1 });
expect(res.status).toBe(409);
});
test('PATCH /:name updates fields', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).patch('/network/resources/plex').send({ port: 32401, displayName: 'Plex!' });
expect(res.status).toBe(200);
expect(res.body.resource).toMatchObject({ port: 32401, displayName: 'Plex!' });
});
test('PATCH /:name: name is immutable; 404 unknown', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const rename = await request(app).patch('/network/resources/plex').send({ name: 'plex2' });
expect(rename.status).toBe(400);
const missing = await request(app).patch('/network/resources/nope').send({ port: 80 });
expect(missing.status).toBe(404);
});
test('DELETE /:name deletes; second delete → 404', async () => {
await request(app).post('/network/resources').send({ name: 'plex', host: 'plex.internal', port: 32400 });
const res = await request(app).delete('/network/resources/plex');
expect(res.status).toBe(200);
expect(res.body.deleted).toBe(true);
const again = await request(app).delete('/network/resources/plex');
expect(again.status).toBe(404);
});
});
describe('network-resources routes: validation', () => {
let dir, app;
beforeEach(() => {
({ app, dir } = createApp());
});
afterEach(() => _cleanup(dir));
test('rejects bad name slugs', async () => {
for (const bad of ['Plex', 'has space', 'under_score', '', 'a/b', 'dot.name']) {
const res = await request(app).post('/network/resources').send({ name: bad, host: 'h', port: 80 });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
}
});
test('rejects ports outside 1-65535 and non-integers', async () => {
for (const bad of [0, -1, 65536, 99999, 80.5, '80', null]) {
const res = await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: bad });
expect(res.status).toBe(400);
}
});
test('rejects empty/missing host', async () => {
const missing = await request(app).post('/network/resources').send({ name: 'plex', port: 80 });
expect(missing.status).toBe(400);
const blank = await request(app).post('/network/resources').send({ name: 'plex', host: ' ', port: 80 });
expect(blank.status).toBe(400);
});
test('rejects bad type/protocol/sensitive', async () => {
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, type: 'vm' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, protocol: 'grpc' })).status).toBe(400);
expect((await request(app).post('/network/resources').send({ name: 'plex', host: 'h', port: 80, sensitive: 'yes' })).status).toBe(400);
});
test('accepts boundary ports 1 and 65535', async () => {
const lo = await request(app).post('/network/resources').send({ name: 'lo', host: 'h', port: 1 });
expect(lo.status).toBe(201);
const hi = await request(app).post('/network/resources').send({ name: 'hi', host: 'h', port: 65535 });
expect(hi.status).toBe(201);
});
});
describe('network-resources routes: sensitive round-trip + seeding', () => {
let dir, app;
afterEach(() => _cleanup(dir));
test('sensitive flag round-trips through create, read, update, re-read', async () => {
({ app, dir } = createApp());
await request(app).post('/network/resources')
.send({ name: 'vault', host: 'vault.internal', port: 8200, sensitive: true });
let res = await request(app).get('/network/resources/vault');
expect(res.status).toBe(200);
expect(res.body.resource.sensitive).toBe(true);
await request(app).patch('/network/resources/vault').send({ sensitive: false });
res = await request(app).get('/network/resources/vault');
expect(res.body.resource.sensitive).toBe(false);
// …and persists on disk (round-trip through a fresh store read).
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'network-resources.json'), 'utf8'));
expect(raw.resources.find(r => r.name === 'vault').sensitive).toBe(false);
});
test('seed skipped silently when services file is missing (manual entries only)', async () => {
const servicesFile = path.join(os.tmpdir(), `no-such-services-${Date.now()}.json`);
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
expect(res.body.count).toBe(0);
// manual creation still works after a skipped seed
const create = await request(app).post('/network/resources')
.send({ name: 'manual', host: 'm.internal', port: 8080 });
expect(create.status).toBe(201);
expect(create.body.resource.manual).toBe(true);
});
test('seed skipped when services file is corrupt JSON (no crash)', async () => {
const badDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-badseed-'));
const servicesFile = path.join(badDir, 'services.json');
fs.writeFileSync(servicesFile, '{not json', 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
_cleanup(badDir);
});
test('seeds from services registry when present (manual:false)', async () => {
const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-seed-'));
const servicesFile = path.join(seedDir, 'services.json');
fs.writeFileSync(servicesFile, JSON.stringify([
{ id: 'jellyfin', port: 8096 },
{ id: 'sonarr', port: 8989, host: 'sonarr.internal' },
{ id: 'bad id!', port: 80 }, // non-slug id → skipped
{ id: 'noport' }, // no port → skipped
]), 'utf8');
({ app, dir } = createApp({ servicesFile }));
const res = await request(app).get('/network/resources');
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const jf = res.body.data.find(r => r.name === 'jellyfin');
expect(jf).toMatchObject({ host: 'localhost', port: 8096, manual: false, sensitive: false });
const so = res.body.data.find(r => r.name === 'sonarr');
expect(so).toMatchObject({ host: 'sonarr.internal', port: 8989, manual: false });
_cleanup(seedDir);
});
});