/** * 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); }); });