/** * Tests for share-store (DC-053). * Coverage: * - issuePublic returns raw token + signature + service-bound metadata * - issuePublic enforces 1h/24h/7d whitelist (other ttls snap to default) * - issueTailscale returns token; service-bound + email-bound * - peek returns public-safe info; signature verification rejects tampering * - peek returns null for unknown/used/expired (no enumeration) * - recordPublicSubscribe increments; caps; rejects expired * - recordTailscaleUse is single-use * - revoke removes by id * - list returns outstanding only (used/expired auto-pruned) * - listForService filters * - signing secret persists across reopens * - dataDir resolver falls back to /tmp when given function/Proxy values */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { createShareStore } = require('../src/security/share-store'); function _tmpDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-sharetest-')); } function _cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); } describe('share-store: issuePublic', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('returns raw token + id + serviceId + expiresAt + urlPath', async () => { const r = await store.issuePublic({ serviceId: 'plex', ttlMs: 60 * 60 * 1000, createdBy: 'admin@x.com' }); expect(r.ok).toBe(true); expect(r.id).toBeTruthy(); expect(r.token.length).toBeGreaterThanOrEqual(40); expect(r.signature.length).toBeGreaterThan(20); expect(r.serviceId).toBe('plex'); expect(r.kind).toBe('public'); expect(r.urlPath).toBe(`/share/${r.token}`); expect(new Date(r.expiresAt).getTime()).toBeGreaterThan(Date.now()); }); test('rejects missing serviceId', async () => { const r = await store.issuePublic({ serviceId: '' }); expect(r.ok).toBe(false); expect(r.reason).toBe('invalid_service'); }); test('snaps unsupported TTLs to default (24h)', async () => { const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 999999 }); expect(r.ok).toBe(true); // default is 24h const diff = new Date(r.expiresAt).getTime() - Date.now(); expect(diff).toBeGreaterThan(23 * 60 * 60 * 1000); expect(diff).toBeLessThan(25 * 60 * 60 * 1000); }); test('allows exactly 1h, 24h, 7d', async () => { for (const ttl of [3_600_000, 86_400_000, 604_800_000]) { const r = await store.issuePublic({ serviceId: 'svc', ttlMs: ttl }); expect(r.ttlMs).toBe(ttl); } }); test('subscribeCap clamps to range', async () => { const r1 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 0 }); expect(r1.ok).toBe(true); // 0 -> default const meta1 = await store.peek(r1.token); expect(meta1.subscribeCap).toBeGreaterThan(0); const r2 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 50 }); expect((await store.peek(r2.token)).subscribeCap).toBe(50); const r3 = await store.issuePublic({ serviceId: 'svc', subscribeCap: 999999 }); expect((await store.peek(r3.token)).subscribeCap).toBe(10000); // clamped }); }); describe('share-store: issueTailscale', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('returns raw token + email + service-bound metadata', async () => { const r = await store.issueTailscale({ serviceId: 'jellyfin', email: 'Friend@Example.COM', ttlMs: 24 * 60 * 60 * 1000, }); expect(r.ok).toBe(true); expect(r.email).toBe('friend@example.com'); // normalized lowercase expect(r.serviceId).toBe('jellyfin'); expect(r.kind).toBe('tailscale'); }); test('rejects missing email', async () => { const r = await store.issueTailscale({ serviceId: 'svc', email: 'nope' }); expect(r.ok).toBe(false); expect(r.reason).toBe('invalid_email'); }); test('rejects missing serviceId', async () => { const r = await store.issueTailscale({ serviceId: '', email: 'a@b.com' }); expect(r.ok).toBe(false); expect(r.reason).toBe('invalid_service'); }); test('clamps TTL to 24h max', async () => { const r = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com', ttlMs: 30 * 24 * 60 * 60 * 1000, // 30d }); expect(r.ok).toBe(true); const diff = new Date(r.expiresAt).getTime() - Date.now(); expect(diff).toBeLessThanOrEqual(24 * 60 * 60 * 1000 + 100); }); }); describe('share-store: peek', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('returns public-safe metadata for a fresh public share', async () => { const issued = await store.issuePublic({ serviceId: 'plex' }); const meta = await store.peek(issued.token); expect(meta).toMatchObject({ kind: 'public', serviceId: 'plex', usedAt: null, }); expect(meta.expiresAt).toBeTruthy(); }); test('returns null for unknown token (no enumeration)', async () => { expect(await store.peek('nope')).toBeNull(); expect(await store.peek('')).toBeNull(); expect(await store.peek(null)).toBeNull(); }); test('returns null for expired token', async () => { const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 }); // tamper: backdate the expiresAt via direct file write const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); const id = Object.keys(data.shares)[0]; data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString(); fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); expect(await store.peek(issued.token)).toBeNull(); }); test('rejects tampered signature', async () => { const issued = await store.issuePublic({ serviceId: 'svc' }); const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); const id = Object.keys(data.shares)[0]; data.shares[id].serviceId = 'attacker-controlled-svc'; // tamper the serviceId data.shares[id].signature = 'tampered' + 'x'.repeat(40); fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); expect(await store.peek(issued.token)).toBeNull(); }); }); describe('share-store: recordPublicSubscribe', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('increments count up to cap, then rejects with cap_reached', async () => { const issued = await store.issuePublic({ serviceId: 'svc', subscribeCap: 3 }); for (let i = 1; i <= 3; i++) { const r = await store.recordPublicSubscribe(issued.token); expect(r.ok).toBe(true); expect(r.count).toBe(i); } const blocked = await store.recordPublicSubscribe(issued.token); expect(blocked.ok).toBe(false); expect(blocked.reason).toBe('cap_reached'); }); test('rejects when token unknown', async () => { const r = await store.recordPublicSubscribe('unknown-token'); expect(r.ok).toBe(false); expect(r.reason).toBe('not_found'); }); test('rejects when wrong kind (Tailscale)', async () => { const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); const r = await store.recordPublicSubscribe(issued.token); expect(r.ok).toBe(false); expect(r.reason).toBe('wrong_kind'); }); test('rejects when expired', async () => { const issued = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 }); const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); const id = Object.keys(data.shares)[0]; data.shares[id].expiresAt = new Date(Date.now() - 1000).toISOString(); fs.writeFileSync(path.join(dir, 'shares.json'), JSON.stringify(data)); const r = await store.recordPublicSubscribe(issued.token); expect(r.ok).toBe(false); expect(r.reason).toBe('expired'); }); }); describe('share-store: recordTailscaleUse', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('marks used on first redemption; second returns already_used', async () => { const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); const r1 = await store.recordTailscaleUse(issued.token, { deviceId: 'device-xyz' }); expect(r1.ok).toBe(true); expect(r1.share.usedAt).toBeTruthy(); expect(r1.share.usedBy).toBe('device-xyz'); const r2 = await store.recordTailscaleUse(issued.token, { deviceId: 'other' }); expect(r2.ok).toBe(false); expect(r2.reason).toBe('already_used'); }); test('rejects wrong kind (public)', async () => { const issued = await store.issuePublic({ serviceId: 'svc' }); const r = await store.recordTailscaleUse(issued.token, { deviceId: 'd' }); expect(r.ok).toBe(false); expect(r.reason).toBe('wrong_kind'); }); test('rejects unknown token', async () => { const r = await store.recordTailscaleUse('nope', { deviceId: 'd' }); expect(r.ok).toBe(false); expect(r.reason).toBe('not_found'); }); }); describe('share-store: revoke + list + listForService', () => { let dir, store; beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); }); afterEach(() => _cleanup(dir)); test('revoke removes by id', async () => { const a = await store.issuePublic({ serviceId: 'svc-a' }); const b = await store.issuePublic({ serviceId: 'svc-b' }); expect(await store.revoke(a.id)).toBe(true); expect(await store.peek(a.token)).toBeNull(); expect(await store.peek(b.token)).not.toBeNull(); }); test('revoke returns false for unknown id', async () => { expect(await store.revoke('nope')).toBe(false); }); test('list returns outstanding only', async () => { await store.issuePublic({ serviceId: 'svc' }); const t = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' }); await store.recordTailscaleUse(t.token, { deviceId: 'd' }); const all = await store.list(); // Tailscale record is terminal (used), pruned; 1 public remains expect(all).toHaveLength(1); expect(all[0].kind).toBe('public'); }); test('listForService filters', async () => { await store.issuePublic({ serviceId: 'svc-a' }); await store.issuePublic({ serviceId: 'svc-b' }); await store.issueTailscale({ serviceId: 'svc-a', email: 'a@b.com' }); const aShares = await store.listForService('svc-a'); expect(aShares).toHaveLength(2); expect(aShares.every(s => s.serviceId === 'svc-a')).toBe(true); }); }); describe('share-store: signing secret persistence + defensive dataDir', () => { test('signing secret persists across reopens', async () => { const dir = _tmpDir(); try { const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' }); const b = await createShareStore({ dataDir: dir }).peek(a.token); expect(b).not.toBeNull(); // same secret, signature still valid } finally { _cleanup(dir); } }); test('falls back to os.tmpdir() when dataDir is missing/function/Proxy', () => { // function value (test-proxy scenario) const fn = () => '/should/not/throw'; const proxy = new Proxy({ dataDir: '/x' }, { get: () => fn }); const s = createShareStore({ dataDir: proxy }); expect(typeof s.issuePublic).toBe('function'); // Should not throw on construction expect(s._file).toContain('shares.json'); }); test('opts.signingSecret overrides persisted secret', async () => { const dir = _tmpDir(); try { const a = await createShareStore({ dataDir: dir }).issuePublic({ serviceId: 'svc' }); // Reopen with a DIFFERENT secret — peek should fail (signature mismatch). const reopen = createShareStore({ dataDir: dir, signingSecret: 'different-secret-' + 'x'.repeat(40) }); const b = await reopen.peek(a.token); expect(b).toBeNull(); } finally { _cleanup(dir); } }); });