239 lines
10 KiB
JavaScript
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);
|
|
});
|
|
});
|