DC-053: Public share links + Tailscale-mediated share (Pro-gated)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- 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.
This commit is contained in:
Krystie
2026-07-21 00:45:46 -07:00
parent f0afc4358c
commit d9e61ce1b7
10 changed files with 1588 additions and 4 deletions
@@ -49,8 +49,14 @@ function readPublicRoutes() {
// Extract excludedPaths from csrf-protection.js
function readCsrfExcluded() {
const content = fs.readFileSync(SRC_CSRF, 'utf8');
// Match string literals in arrays inside excludedPaths
const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
// Match string literals in arrays inside excludedPaths.
// The naive `[^\]]+` regex used to work but breaks once any comment line
// between entries contains a quoted word (e.g. "token's TTL") — the
// inner-quote regex then captures the comment text as a fake path.
// Fix: strip line comments (`// ...`) before scanning. Block comments
// don't appear in this file.
const stripped = content.replace(/\/\/[^\n]*/g, '');
const blockMatch = stripped.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
if (!blockMatch) return new Set();
const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
return new Set(entries);
@@ -123,6 +129,7 @@ function readMountedRoutes() {
'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
];
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
const prefixMap = {
@@ -145,7 +152,27 @@ function readMountedRoutes() {
if (typeof factory !== 'function') continue;
let router;
try {
router = factory(universalDeps);
// Per-mount deps override: factories that need a real implementation
// of a particular dep (not just a noopFn proxy) get one here. Without
// this, DC-053's shareRoutes returns an empty 404 router in the test
// (because universalDeps.shareStore.issuePublic is undefined), and the
// walker never sees the real /share/:token/* paths.
const deps = relPath === 'routes/share.js'
? Object.assign({}, universalDeps, {
shareStore: {
issuePublic: () => ({ ok: true }),
issueTailscale: () => ({ ok: true }),
peek: () => null,
getRaw: () => null,
recordPublicSubscribe: () => ({ ok: true }),
recordTailscaleUse: () => ({ ok: true }),
revoke: () => true,
list: () => [],
listForService: () => [],
},
})
: universalDeps;
router = factory(deps);
} catch (e) { continue; }
// Every direct mount is on apiRouter (which lives at /api/v1) plus an
// optional explicit prefix from src/app.js. Walk with the combined prefix
@@ -0,0 +1,449 @@
/**
* 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;
}
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* 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); }
});
});