- 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.
312 lines
12 KiB
JavaScript
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); }
|
|
});
|
|
}); |