Money: private Documents page with admin-only file endpoint
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Lists contracts/pension docs with written summaries, an inline viewer and download at /trackers/money/documents. Files and summaries live on the host (MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in git or static/; the file endpoint is admin-only (401/403) and private, no-store.
This commit is contained in:
12
README.md
12
README.md
@@ -267,6 +267,18 @@ are created by `npm run db:migrate:money-tracker`; apply that migration to each
|
|||||||
environment before deploying the new route. The deployment preflight checks for
|
environment before deploying the new route. The deployment preflight checks for
|
||||||
all nine tracker tables and the projection columns.
|
all nine tracker tables and the projection columns.
|
||||||
|
|
||||||
|
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
|
||||||
|
written summary, an inline viewer and a download. Nothing about them is in git: the
|
||||||
|
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
|
||||||
|
`/money-documents` when it exists, otherwise `~/money-documents`) as
|
||||||
|
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
|
||||||
|
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
|
||||||
|
`/home/stan/money-documents` read-only at `/money-documents` when it has a
|
||||||
|
`documents.json`. Files are only streamed to admins via
|
||||||
|
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
|
||||||
|
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
|
||||||
|
no redeploy is needed.
|
||||||
|
|
||||||
Transactions can be entered manually or imported from a CSV or tab-separated
|
Transactions can be entered manually or imported from a CSV or tab-separated
|
||||||
export with `date,account,description,amount` headers. Dates accept
|
export with `date,account,description,amount` headers. Dates accept
|
||||||
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros
|
||||||
|
|||||||
@@ -58,6 +58,31 @@ YAML
|
|||||||
else
|
else
|
||||||
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
|
echo "No $books_host_dir/library on this host; book files will not be readable" >&2
|
||||||
fi
|
fi
|
||||||
|
# Private money documents (contracts, pension rules) + their summaries also live on the host,
|
||||||
|
# outside the repo, bind-mounted read-only at /money-documents. The app streams them only to an
|
||||||
|
# admin via /trackers/money/documents/<id>/file. Layout: documents.json + files/<file>.
|
||||||
|
money_documents_host_dir=${MONEY_DOCUMENTS_HOST_DIR:-/home/stan/money-documents}
|
||||||
|
case "$money_documents_host_dir" in /*) ;; *) echo 'MONEY_DOCUMENTS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
|
||||||
|
if sudo docker run --rm --entrypoint sh \
|
||||||
|
--mount "type=bind,source=$money_documents_host_dir,target=/money-documents,readonly" "$image" \
|
||||||
|
-c 'test -f /money-documents/documents.json' 2>/dev/null; then
|
||||||
|
money_documents_override="$HOME/compose.money-documents.yml"
|
||||||
|
rm -f "$money_documents_override"
|
||||||
|
cat >"$money_documents_override" <<YAML
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
MONEY_DOCUMENTS_ROOT: /money-documents
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: $money_documents_host_dir
|
||||||
|
target: /money-documents
|
||||||
|
read_only: true
|
||||||
|
YAML
|
||||||
|
compose_files+=(-f "$money_documents_override")
|
||||||
|
else
|
||||||
|
echo "No $money_documents_host_dir/documents.json on this host; money documents will not be readable" >&2
|
||||||
|
fi
|
||||||
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
|
||||||
switched=0
|
switched=0
|
||||||
rollback() {
|
rollback() {
|
||||||
|
|||||||
52
src/lib/server/money-documents.test.ts
Normal file
52
src/lib/server/money-documents.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
listMoneyDocuments,
|
||||||
|
parseManifest,
|
||||||
|
resolveDocumentFile,
|
||||||
|
validDocumentId
|
||||||
|
} from './money-documents';
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
root = await mkdtemp(path.join(tmpdir(), 'money-docs-'));
|
||||||
|
await mkdir(path.join(root, 'files'), { recursive: true });
|
||||||
|
await writeFile(path.join(root, 'files', 'contract.pdf'), '%PDF-1.7');
|
||||||
|
await writeFile(path.join(root, 'secret.txt'), 'nope');
|
||||||
|
await symlink(path.join(root, 'secret.txt'), path.join(root, 'files', 'escape.pdf'));
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, 'documents.json'),
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 'contract', title: 'Contract', file: 'contract.pdf', date: '2026-09-22', pages: 11 },
|
||||||
|
{ id: 'contract', title: 'Duplicate', file: 'contract.pdf' },
|
||||||
|
{ id: '../bad', title: 'Bad', file: 'x.pdf' },
|
||||||
|
{ id: 'no-file', title: 'No file' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterAll(() => rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('money documents', () => {
|
||||||
|
it('validates ids', () => {
|
||||||
|
expect(validDocumentId('cargill-contract-2026')).toBe(true);
|
||||||
|
expect(validDocumentId('../etc')).toBe(false);
|
||||||
|
expect(validDocumentId('')).toBe(false);
|
||||||
|
});
|
||||||
|
it('keeps only well-formed, unique manifest entries', async () => {
|
||||||
|
const docs = await listMoneyDocuments(root);
|
||||||
|
expect(docs.map((d) => d.id)).toEqual(['contract']);
|
||||||
|
expect(docs[0]).toMatchObject({ category: 'Other', date: '2026-09-22', pages: 11 });
|
||||||
|
expect(parseManifest({ not: 'an array' })).toEqual([]);
|
||||||
|
});
|
||||||
|
it('returns no documents when the manifest is missing', async () => {
|
||||||
|
expect(await listMoneyDocuments(path.join(root, 'missing'))).toEqual([]);
|
||||||
|
});
|
||||||
|
it('resolves files inside files/ only', async () => {
|
||||||
|
expect(await resolveDocumentFile('contract.pdf', root)).toMatch(/contract\.pdf$/);
|
||||||
|
expect(await resolveDocumentFile('../secret.txt', root)).toBeNull();
|
||||||
|
expect(await resolveDocumentFile('escape.pdf', root)).toBeNull();
|
||||||
|
expect(await resolveDocumentFile('/etc/passwd', root)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/lib/server/money-documents.ts
Normal file
91
src/lib/server/money-documents.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Private money documents (employment contracts, pension rules, ...). The files and their
|
||||||
|
// written summaries live on the host, outside the repo, and are bind-mounted read-only at
|
||||||
|
// /money-documents in prod (see scripts/deploy-remote.sh). They are only ever streamed to an
|
||||||
|
// administrator through /trackers/money/documents/[id]/file; nothing is under static/ or in git.
|
||||||
|
//
|
||||||
|
// Layout of MONEY_DOCUMENTS_ROOT:
|
||||||
|
// documents.json manifest: [{ id, title, category, date, file, pages?, summary }]
|
||||||
|
// files/<file> the documents themselves (file is relative to files/)
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export type MoneyDocument = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
date: string | null;
|
||||||
|
file: string;
|
||||||
|
pages: number | null;
|
||||||
|
/** Markdown, written by hand for this document. */
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function moneyDocumentsRoot(): string {
|
||||||
|
if (process.env.MONEY_DOCUMENTS_ROOT) return process.env.MONEY_DOCUMENTS_ROOT;
|
||||||
|
return existsSync('/money-documents/documents.json')
|
||||||
|
? '/money-documents'
|
||||||
|
: path.join(homedir(), 'money-documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
||||||
|
export function validDocumentId(id: string): boolean {
|
||||||
|
return ID.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates a parsed manifest; entries that are malformed are skipped, never thrown. */
|
||||||
|
export function parseManifest(raw: unknown): MoneyDocument[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const documents: MoneyDocument[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const item = entry as Record<string, unknown>;
|
||||||
|
const id = text(item.id);
|
||||||
|
const title = text(item.title);
|
||||||
|
const file = text(item.file);
|
||||||
|
if (!id || !validDocumentId(id) || seen.has(id) || !title || !file) continue;
|
||||||
|
const date = text(item.date);
|
||||||
|
seen.add(id);
|
||||||
|
documents.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
category: text(item.category) ?? 'Other',
|
||||||
|
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null,
|
||||||
|
file,
|
||||||
|
pages: typeof item.pages === 'number' && item.pages > 0 ? Math.floor(item.pages) : null,
|
||||||
|
summary: typeof item.summary === 'string' ? item.summary : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMoneyDocuments(root = moneyDocumentsRoot()): Promise<MoneyDocument[]> {
|
||||||
|
try {
|
||||||
|
return parseManifest(JSON.parse(await readFile(path.join(root, 'documents.json'), 'utf8')));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a manifest file inside <root>/files, refusing anything that escapes it. */
|
||||||
|
export async function resolveDocumentFile(
|
||||||
|
relative: string,
|
||||||
|
root = moneyDocumentsRoot()
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
|
||||||
|
const base = path.join(root, 'files');
|
||||||
|
try {
|
||||||
|
const realBase = await realpath(base);
|
||||||
|
const real = await realpath(path.join(base, relative));
|
||||||
|
if (!real.startsWith(realBase + path.sep)) return null;
|
||||||
|
return (await stat(real)).isFile() ? real : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
import Upload from 'lucide-svelte/icons/upload';
|
import Upload from 'lucide-svelte/icons/upload';
|
||||||
import Download from 'lucide-svelte/icons/download';
|
import Download from 'lucide-svelte/icons/download';
|
||||||
|
import FileText from 'lucide-svelte/icons/file-text';
|
||||||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||||
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
|
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
|
||||||
@@ -281,7 +282,9 @@
|
|||||||
<h1>Money</h1>
|
<h1>Money</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<Button variant="outline" onclick={() => open('import')}
|
<Button variant="outline" href={resolve('/trackers/money/documents')}
|
||||||
|
><FileText size={16} /> Documents</Button
|
||||||
|
><Button variant="outline" onclick={() => open('import')}
|
||||||
><Upload size={16} /> Import CSV</Button
|
><Upload size={16} /> Import CSV</Button
|
||||||
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
|
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
src/routes/trackers/money/documents/+page.server.ts
Normal file
20
src/routes/trackers/money/documents/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
|
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||||
|
import { listMoneyDocuments } from '#lib/server/money-documents.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) redirect(303, '/login?returnTo=/trackers/money/documents');
|
||||||
|
requireAdmin(event);
|
||||||
|
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
const documents = await listMoneyDocuments();
|
||||||
|
return {
|
||||||
|
documents: documents.map(({ file, summary, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
fileName: file.split('/').at(-1) ?? file,
|
||||||
|
// The manifest is written by the owner on the host, not user input.
|
||||||
|
summaryHtml: renderMarkdown(summary)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
128
src/routes/trackers/money/documents/+page.svelte
Normal file
128
src/routes/trackers/money/documents/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '#lib/components/ui/button/index.js';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import Download from 'lucide-svelte/icons/download';
|
||||||
|
import Eye from 'lucide-svelte/icons/eye';
|
||||||
|
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||||
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let viewing = $state<string | null>(null);
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-NL', { dateStyle: 'medium' });
|
||||||
|
function dateLabel(date: string) {
|
||||||
|
return dateFormat.format(new Date(date + 'T12:00:00'));
|
||||||
|
}
|
||||||
|
function fileHref(id: string, download = false) {
|
||||||
|
const href = resolve('/trackers/money/documents/[id]/file', { id });
|
||||||
|
return download ? href + '?download' : href;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Documents · Money</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-screen-lg space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<header>
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
|
||||||
|
href={resolve('/trackers/money')}><ArrowLeft size={14} /> Money</a
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold">Documents</h1>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
Contracts and other paperwork, each with a summary of what matters for money. Private: only
|
||||||
|
visible to you.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.documents.length === 0}
|
||||||
|
<p class="rounded-lg border p-6 text-sm opacity-70">No documents yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each data.documents as document (document.id)}
|
||||||
|
<article class="space-y-4 rounded-lg border p-5" id={document.id}>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-lg font-semibold">{document.title}</h2>
|
||||||
|
<p class="flex flex-wrap items-center gap-2 text-sm opacity-70">
|
||||||
|
<Badge variant="secondary">{document.category}</Badge>
|
||||||
|
{#if document.date}<span>{dateLabel(document.date)}</span>{/if}
|
||||||
|
{#if document.pages}<span>· {document.pages} pages</span>{/if}
|
||||||
|
<span>· {document.fileName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-expanded={viewing === document.id}
|
||||||
|
onclick={() => (viewing = viewing === document.id ? null : document.id)}
|
||||||
|
>
|
||||||
|
{#if viewing === document.id}<EyeOff size={14} /> Hide{:else}<Eye size={14} /> View{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" href={fileHref(document.id)} target="_blank"
|
||||||
|
><ExternalLink size={14} /> Open</Button
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" href={fileHref(document.id, true)}
|
||||||
|
><Download size={14} /> Download</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if viewing === document.id}
|
||||||
|
<iframe
|
||||||
|
class="h-[75vh] w-full rounded border"
|
||||||
|
title={document.title}
|
||||||
|
src={fileHref(document.id)}
|
||||||
|
></iframe>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="document-summary text-sm leading-relaxed">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written manifest on the host -->
|
||||||
|
{@html document.summaryHtml}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.document-summary :global {
|
||||||
|
h3 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
table {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, currentColor 15%, transparent);
|
||||||
|
padding: 0.3rem 0.5rem 0.3rem 0;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/routes/trackers/money/documents/[id]/file/+server.ts
Normal file
32
src/routes/trackers/money/documents/[id]/file/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Admin-only stream of a private money document (contracts etc.). Logged-out visitors get 401,
|
||||||
|
// non-admins 403. Files come from MONEY_DOCUMENTS_ROOT/files on the host, never from static/.
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { requireAdmin } from '#lib/server/authorization.js';
|
||||||
|
import { streamFile } from '#lib/server/books/files.js';
|
||||||
|
import {
|
||||||
|
listMoneyDocuments,
|
||||||
|
resolveDocumentFile,
|
||||||
|
validDocumentId
|
||||||
|
} from '#lib/server/money-documents.js';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
const handler: RequestHandler = async (event) => {
|
||||||
|
requireAdmin(event);
|
||||||
|
const id = event.params.id;
|
||||||
|
const document = validDocumentId(id)
|
||||||
|
? (await listMoneyDocuments()).find((item) => item.id === id)
|
||||||
|
: undefined;
|
||||||
|
const resolved = document ? await resolveDocumentFile(document.file) : null;
|
||||||
|
if (!resolved) error(404, 'Not Found');
|
||||||
|
const name = resolved.split('/').at(-1) ?? 'document';
|
||||||
|
const disposition = event.url.searchParams.has('download') ? 'attachment' : 'inline';
|
||||||
|
return streamFile(resolved, event.request, {
|
||||||
|
'cache-control': 'private, no-store',
|
||||||
|
'x-robots-tag': 'noindex, nofollow, noarchive',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'content-disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(name)}`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = handler;
|
||||||
|
export const HEAD = handler;
|
||||||
58
src/routes/trackers/money/documents/auth.test.ts
Normal file
58
src/routes/trackers/money/documents/auth.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Money documents are admin-only: the page redirects logged-out visitors to /login, and the
|
||||||
|
// file endpoint answers 401 logged out and 403 for non-admins without touching the disk.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { isHttpError, isRedirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
vi.mock('#lib/server/money-documents.js', async (original) => ({
|
||||||
|
...(await original<typeof import('#lib/server/money-documents.js')>()),
|
||||||
|
listMoneyDocuments: vi.fn(async () => []),
|
||||||
|
resolveDocumentFile: vi.fn(async () => null)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as documents from '#lib/server/money-documents.js';
|
||||||
|
import { GET } from './[id]/file/+server';
|
||||||
|
import { load } from './+page.server';
|
||||||
|
|
||||||
|
const ADMIN = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
|
||||||
|
const MEMBER = { id: 'u2', email: 'someone@example.com', role: 'user' };
|
||||||
|
|
||||||
|
function event(user: unknown, id = 'contract') {
|
||||||
|
const url = new URL(`https://stanrunge.dev/trackers/money/documents/${id}/file`);
|
||||||
|
return {
|
||||||
|
locals: { user },
|
||||||
|
params: { id },
|
||||||
|
url,
|
||||||
|
request: new Request(url),
|
||||||
|
setHeaders: vi.fn()
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status(run: () => unknown) {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (err) {
|
||||||
|
if (isHttpError(err)) return err.status;
|
||||||
|
if (isRedirect(err)) return err.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('money documents auth', () => {
|
||||||
|
it('file endpoint: 401 logged out, 403 non-admin, no disk access', async () => {
|
||||||
|
expect(await status(() => GET(event(null)))).toBe(401);
|
||||||
|
expect(await status(() => GET(event(MEMBER)))).toBe(403);
|
||||||
|
expect(documents.listMoneyDocuments).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('file endpoint: 404 for unknown ids for the admin', async () => {
|
||||||
|
expect(await status(() => GET(event(ADMIN, 'nope')))).toBe(404);
|
||||||
|
expect(await status(() => GET(event(ADMIN, '..')))).toBe(404);
|
||||||
|
});
|
||||||
|
it('page redirects logged-out visitors and rejects non-admins', async () => {
|
||||||
|
expect(await status(() => load(event(null)))).toBe(303);
|
||||||
|
expect(await status(() => load(event(MEMBER)))).toBe(403);
|
||||||
|
expect(await status(() => load(event(ADMIN)))).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user