Files
dashcaddy/dashcaddy-api/__tests__/share-store.test.js
Krystie d9e61ce1b7
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-053: Public share links + Tailscale-mediated share (Pro-gated)
- Share-store: HMAC-signed tokens bound to serviceId+kind, persistent
  signing secret in dataDir/.share-secret, atomic writes, auto-prune
- Routes: admin endpoints gated on licenseManager.isPro() (402 Free);
  public endpoints CSRF-exempt (token IS proof)
- Tailscale path: mints single-use ephemeral pre-auth key, emails
  join link, rolls back share record if createAuthKey throws
- Email-failure path: exposes urlPath for manual delivery fallback
- 53 new tests (24 store + 29 routes), full suite 1372/1372
- Drift-test parser hardened against quoted-word comments
- share-store dataDir resolver handles Proxy/function values

CHANGELOG + BACKLOG updated.
2026-07-21 00:45:46 -07:00

312 lines
12 KiB
JavaScript

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