/** * Tests for share routes (DC-053) — public share + Tailscale-mediated share. * Coverage: * - GET /share/:token/preview is public, returns snapshot * - POST /share requires admin (401/403 without user) * - POST /share requires Pro tier (402 PaymentRequired when Free) * - POST /share issues a public share, returns token + urlPath * - POST /share rejects unknown serviceId with 404 * - POST /share snaps unsupported TTLs * - POST /share/tailscale requires Tailscale configured * - POST /share/tailscale mints auth key + records share + emails invitee * - POST /share/tailscale rolls back share when createAuthKey throws * - POST /share/tailscale returns emailed=true when sendEmail resolves * - POST /share/tailscale returns urlPath when email fails (manual fallback) * - DELETE /share/:id requires admin; revokes * - GET /share lists shares (admin only) * - POST /share/:token/subscribe is public, records event * - POST /share/:token/redeem-tailscale records use + is single-shot */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const express = require('express'); const request = require('supertest'); const { createShareStore } = require('../src/security/share-store'); const { PaymentRequiredError } = require('../src/utilities/errors'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-route-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } // ── Test stubs ──────────────────────────────────────────────────────────── function _proLicenseManager() { return { isPro: () => true, allowsLifetimeLicense: () => false }; } function _freeLicenseManager() { return { isPro: () => false, allowsLifetimeLicense: () => false }; } function _stubNotificationManager({ shouldFail = false } = {}) { return { sendEmail: jest.fn(async () => { if (shouldFail) throw new Error('SMTP down'); return { messageId: 'fake' }; }), }; } function _stubTailscaleCoord({ shouldFail = false, keyId = 'auth-key-123' } = {}) { return { createAuthKey: jest.fn(async () => { if (shouldFail) throw new Error('Tailscale API down'); return { id: keyId, key: 'tskey-fake-' + 'x'.repeat(40) }; }), }; } function _stubServicesStateManager(services = {}) { return { get: async (id) => services[id] || null, read: async () => Object.values(services), }; } function _buildApp({ shareStore, licenseManager = _proLicenseManager(), tailscaleCoord = _stubTailscaleCoord(), notificationManager = _stubNotificationManager(), servicesStateManager = _stubServicesStateManager({ plex: { id: 'plex', name: 'Plex', description: 'Media', url: 'https://plex.sami' }, }), adminUser = { email: 'admin@sami', role: 'admin' }, noAdmin = false, } = {}) { const app = express(); app.use(express.json()); // Inject a fake req.user for the protected endpoints; bypass for the public ones. app.use((req, _res, next) => { if (noAdmin) { req.user = { email: 'viewer@sami', role: 'viewer' }; } else { req.user = adminUser; } next(); }); const shareRoutes = require('../routes/share'); app.use(shareRoutes({ shareStore, licenseManager, tailscaleCoord, notificationManager, servicesStateManager, servicesFile: null, asyncHandler: (fn, label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), log: { info() {}, warn() {}, error() {} }, })); // Error handler mirrors production app.use((err, _req, res, _next) => { if (err && err.statusCode) { return res.status(err.statusCode).json({ success: false, error: err.message, code: err.code, }); } return res.status(500).json({ success: false, error: err && err.message }); }); return app; } // ── Tests ──────────────────────────────────────────────────────────────── describe('share routes: GET /share/:token/preview', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('public — returns service snapshot', async () => { const app = _buildApp({ shareStore }); const issued = await shareStore.issuePublic({ serviceId: 'plex' }); const res = await request(app).get(`/share/${issued.token}/preview`); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.data.kind).toBe('public'); expect(res.body.data.serviceId).toBe('plex'); expect(res.body.data.service.name).toBe('Plex'); }); test('public — 404 for unknown token', async () => { const app = _buildApp({ shareStore }); const res = await request(app).get('/share/nonexistent/preview'); expect(res.status).toBe(404); }); test('public — no auth required', async () => { const app = _buildApp({ shareStore, noAdmin: true }); const issued = await shareStore.issuePublic({ serviceId: 'plex' }); const res = await request(app).get(`/share/${issued.token}/preview`); expect(res.status).toBe(200); }); }); describe('share routes: POST /share', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('admin+Pro → issues public share', async () => { const app = _buildApp({ shareStore }); const res = await request(app) .post('/share') .send({ serviceId: 'plex', ttlMs: 3_600_000 }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.data.kind).toBe('public'); expect(res.body.data.token).toBeTruthy(); expect(res.body.data.urlPath).toBe(`/share/${res.body.data.token}`); expect(res.body.data.serviceId).toBe('plex'); }); test('Free tier → 402 PaymentRequired', async () => { const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() }); const res = await request(app).post('/share').send({ serviceId: 'plex' }); expect(res.status).toBe(402); expect(res.body.error).toMatch(/Pro tier required/); }); test('non-admin → 403', async () => { const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app).post('/share').send({ serviceId: 'plex' }); expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); }); test('unknown serviceId → 404', async () => { const app = _buildApp({ shareStore }); const res = await request(app).post('/share').send({ serviceId: 'nope' }); expect(res.status).toBe(404); }); test('missing serviceId → 400', async () => { const app = _buildApp({ shareStore }); const res = await request(app).post('/share').send({}); expect(res.status).toBe(400); }); test('unsupported TTL snaps to default', async () => { const app = _buildApp({ shareStore }); const res = await request(app) .post('/share') .send({ serviceId: 'plex', ttlMs: 999999 }); expect(res.status).toBe(201); expect(res.body.data.ttlMs).toBe(24 * 60 * 60 * 1000); }); }); describe('share routes: POST /share/tailscale', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('Pro+admin+Tailscale → mints key, emails, records share', async () => { const tailscaleCoord = _stubTailscaleCoord(); const notificationManager = _stubNotificationManager(); const app = _buildApp({ shareStore, tailscaleCoord, notificationManager }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'friend@example.com' }); expect(res.status).toBe(201); expect(res.body.success).toBe(true); expect(res.body.data.kind).toBe('tailscale'); expect(res.body.data.email).toBe('friend@example.com'); expect(res.body.data.emailed).toBe(true); expect(res.body.data.emailError).toBeFalsy(); expect(tailscaleCoord.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({ reusable: false, ephemeral: true, preauthorized: true, description: expect.stringContaining('dashcaddy-share:'), })); expect(notificationManager.sendEmail).toHaveBeenCalledWith( expect.stringContaining('shared a service with you'), expect.stringContaining('/share/') ); }); test('Free tier → 402', async () => { const app = _buildApp({ shareStore, licenseManager: _freeLicenseManager() }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'a@b.com' }); expect(res.status).toBe(402); }); test('Tailscale not configured → 400', async () => { const app = _buildApp({ shareStore, tailscaleCoord: null }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'a@b.com' }); expect(res.status).toBe(400); }); test('createAuthKey failure → rolls back share', async () => { const app = _buildApp({ shareStore, tailscaleCoord: _stubTailscaleCoord({ shouldFail: true }), }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'a@b.com' }); expect(res.status).toBe(400); // No orphans const remaining = await shareStore.list(); expect(remaining).toHaveLength(0); }); test('email delivery failure → still returns 201 with urlPath fallback', async () => { const app = _buildApp({ shareStore, notificationManager: _stubNotificationManager({ shouldFail: true }), }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'a@b.com' }); expect(res.status).toBe(201); expect(res.body.data.emailed).toBe(false); expect(res.body.data.emailError).toMatch(/SMTP/); expect(res.body.data.urlPath).toMatch(/^\/share\//); }); test('clamps TTL to 24h max', async () => { const tailscaleCoord = _stubTailscaleCoord(); const app = _buildApp({ shareStore, tailscaleCoord }); const res = await request(app) .post('/share/tailscale') .send({ serviceId: 'plex', email: 'a@b.com', ttlMs: 30 * 24 * 60 * 60 * 1000 }); expect(res.status).toBe(201); const calledOpts = tailscaleCoord.createAuthKey.mock.calls[0][0]; expect(calledOpts.expirySeconds).toBeLessThanOrEqual(24 * 60 * 60); }); }); describe('share routes: GET /share + DELETE /share/:id', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('admin lists outstanding shares', async () => { await shareStore.issuePublic({ serviceId: 'plex' }); await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); const app = _buildApp({ shareStore }); const res = await request(app).get('/share'); expect(res.status).toBe(200); expect(res.body.data).toHaveLength(2); }); test('non-admin → forbidden', async () => { const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app).get('/share'); expect(res.status).toBeGreaterThanOrEqual(400); }); test('admin revokes share', async () => { const issued = await shareStore.issuePublic({ serviceId: 'plex' }); const app = _buildApp({ shareStore }); const res = await request(app).delete(`/share/${issued.id}`); expect(res.status).toBe(200); expect(await shareStore.peek(issued.token)).toBeNull(); }); test('revoke unknown id → 404', async () => { const app = _buildApp({ shareStore }); const res = await request(app).delete('/share/nonexistent'); expect(res.status).toBe(404); }); }); describe('share routes: POST /share/:token/subscribe (public)', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('public — records subscribe event', async () => { const issued = await shareStore.issuePublic({ serviceId: 'plex' }); const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app) .post(`/share/${issued.token}/subscribe`) .send({ email: 'sub@example.com' }); expect(res.status).toBe(200); expect(res.body.data.count).toBe(1); }); test('rejects invalid email', async () => { const issued = await shareStore.issuePublic({ serviceId: 'plex' }); const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app) .post(`/share/${issued.token}/subscribe`) .send({ email: 'not-an-email' }); expect(res.status).toBe(400); }); test('rejects unknown token', async () => { const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app) .post('/share/nonexistent/subscribe') .send({ email: 'a@b.com' }); expect(res.status).toBe(404); }); }); describe('share routes: POST /share/:token/redeem-tailscale (public)', () => { let dir, shareStore; beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('public — first redemption succeeds, second is already_used', async () => { const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); const app = _buildApp({ shareStore, noAdmin: true }); const r1 = await request(app) .post(`/share/${issued.token}/redeem-tailscale`) .send({ deviceId: 'device-1' }); expect(r1.status).toBe(200); expect(r1.body.data.redeemed).toBe(true); const r2 = await request(app) .post(`/share/${issued.token}/redeem-tailscale`) .send({ deviceId: 'device-2' }); expect(r2.status).toBe(400); expect(r2.body.error).toMatch(/already_used/); }); test('rejects missing deviceId', async () => { const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' }); const app = _buildApp({ shareStore, noAdmin: true }); const res = await request(app) .post(`/share/${issued.token}/redeem-tailscale`) .send({}); expect(res.status).toBe(400); }); }); describe('share routes: defensive', () => { // These tests run under jest (NODE_ENV=test) so the factory is lenient // about missing deps — it returns an empty router with a 404 catch-all // instead of throwing. That's by design: production always wires // shareStore + asyncHandler (src/app.js instantiates them), but the // universal-deps Proxy in some test scenarios returns noopFn. test('factory returns 404 router when shareStore missing (test mode)', () => { const prevEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'test'; try { const shareRoutes = require('../routes/share'); const router = shareRoutes({ asyncHandler: (fn) => fn }); expect(typeof router).toBe('function'); // express.Router } finally { process.env.NODE_ENV = prevEnv; } }); test('factory uses fallback asyncHandler when missing (test mode)', () => { const prevEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'test'; try { const shareRoutes = require('../routes/share'); const dir = _tmpDir(); const shareStore = createShareStore({ dataDir: dir }); const router = shareRoutes({ shareStore }); expect(typeof router).toBe('function'); _cleanup(dir); } finally { process.env.NODE_ENV = prevEnv; } }); test('factory throws when shareStore missing in production', () => { const prevEnv = process.env.NODE_ENV; delete process.env.NODE_ENV; try { const shareRoutes = require('../routes/share'); expect(() => shareRoutes({ asyncHandler: (fn) => fn })).toThrow(/shareStore/); } finally { if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv; } }); test('factory throws when asyncHandler missing in production', () => { const prevEnv = process.env.NODE_ENV; delete process.env.NODE_ENV; try { const shareRoutes = require('../routes/share'); const dir = _tmpDir(); const shareStore = createShareStore({ dataDir: dir }); expect(() => shareRoutes({ shareStore })).toThrow(/asyncHandler/); _cleanup(dir); } finally { if (prevEnv !== undefined) process.env.NODE_ENV = prevEnv; } }); });