refactor(fase0): shared scaffold — crypto, file-url, schemas, test splits

- Add zod dep; new src/shared/validation/schemas.ts (BucketName,
  JsonUploadPayload, LoginBody, DeleteObjects, CompleteMultipart,
  clampMaxKeys, parseOrNull) + test/validation.test.ts
- Dedup timingSafeCompare -> src/shared/utils/crypto.ts (auth
  middleware, authenticate use-case, s3/auth now import it)
- Dedup AuthSession -> single type in dto/auth.ts
- Dedup telegram file URL builder + filename sanitizer to shared
  modules (file-controller now imports the canonical ones)
- Remove duplicate controllers/home.html (canonical: src/home.html)
- Fix stale bootstrap mocks to real module paths; bootstrap now
  asserts public GET vs guarded POST separately
- Fix health assertion to include version field
- package.json: test -> test:unit alias; new test:quarantine for
  network/live tests; register previously unlisted test files
This commit is contained in:
asepharyana committed 2026-09-14 17:09:52 +07:00
1 parent f4b30cef0c
commit 5d01a9405f
16 files changed
+511 -473

No files matched your search

+3
View File
@@ -13,6 +13,7 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"telegraf": "^4.15.0", "telegraf": "^4.15.0",
"winston": "^3.11.0", "winston": "^3.11.0",
"zod": "^4.6.5",
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.1079.0", "@aws-sdk/client-s3": "^3.1079.0",
@@ -357,6 +358,8 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], "bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
+6 -3
View File
@@ -8,11 +8,13 @@
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js", "build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
"start": "NODE_ENV=production bun dist/index.js", "start": "NODE_ENV=production bun dist/index.js",
"db:migrate": "bun dist/migrate.js", "db:migrate": "bun dist/migrate.js",
"test": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts &Line truncated "test": "bun run test:unit",
"test:unit": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.tsLine truncated
"test:quarantine": "bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-docker-registry.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-sdk.test.ts && bun test --preload ./test/helpers/setup-env.ts test/production-e2e.test.ts",
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts", "test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts", "test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts", "test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
"test:s3": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-range.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-object-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/object-stream-parallel.test.ts && bun test --preload ./Line truncated "test:s3": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-range.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-object-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/object-stream-parallel.test.ts && bun test --preload ./Line truncated
"lint": "bunx biome check src test", "lint": "bunx biome check src test",
"format": "bunx biome format --write src test", "format": "bunx biome format --write src test",
"prepare": "husky" "prepare": "husky"
@@ -25,7 +27,8 @@
"pg": "^8.11.0", "pg": "^8.11.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"telegraf": "^4.15.0", "telegraf": "^4.15.0",
"winston": "^3.11.0" "winston": "^3.11.0",
"zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.1079.0", "@aws-sdk/client-s3": "^3.1079.0",
+1 -19
View File
@@ -1,4 +1,4 @@
import { timingSafeEqual } from 'node:crypto'; import { timingSafeCompare } from '../../shared/utils/crypto';
import type { import type {
AuthSession, AuthSession,
LoginInput, LoginInput,
@@ -23,24 +23,6 @@ export interface AuthenticateUseCaseDeps {
config: AuthUseCaseConfig; config: AuthUseCaseConfig;
} }
/**
* Performs a constant-time string comparison to prevent timing attacks.
*
* @param left - The first string to compare.
* @param right - The second string to compare.
* @returns `true` if the strings are equal, `false` otherwise.
*/
const timingSafeCompare = (left: string, right: string): boolean => {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
};
/** /**
* Checks whether authentication is enabled based on the configured token. * Checks whether authentication is enabled based on the configured token.
* *
+19
View File
@@ -0,0 +1,19 @@
/**
* Builds a Telegram CDN download URL from a file path and bot token.
*
* Single canonical implementation — previously constructed inline in
* `interfaces/http/controllers/file-controller.ts`,
* `interfaces/http/controllers/web-api-controller.ts`,
* `interfaces/http/controllers/s3-controller.ts`, and
* `infrastructure/telegram/chunked-storage.ts`.
*
* NOTE: URLs produced here embed the bot token. They must only be used
* server-side (outbound fetch to the Telegram CDN), never exposed to
* clients in redirects or response bodies.
*
* @param filePath - The Telegram file path returned by getFile.
* @param botToken - The bot token used to authenticate the download.
* @returns The full Telegram CDN URL.
*/
export const buildTelegramFileUrl = (filePath: string, botToken: string): string =>
`https://api.telegram.org/file/bot${botToken}/${filePath}`;
@@ -4,6 +4,8 @@ import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
import { fileInfoCache } from '../../../infrastructure/cache/index'; import { fileInfoCache } from '../../../infrastructure/cache/index';
import { chunkedStorage, fileRepository } from '../../../infrastructure/di'; import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
import { botPool } from '../../../infrastructure/telegram/bot-pool'; import { botPool } from '../../../infrastructure/telegram/bot-pool';
import { buildTelegramFileUrl } from '../../../infrastructure/telegram/file-url';
import { sanitizeFilenameHeader } from '../../../shared/http/filename';
import logger from '../../../shared/logger/index'; import logger from '../../../shared/logger/index';
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file'; import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
import { locateZipEntry } from '../../../shared/utils/zip'; import { locateZipEntry } from '../../../shared/utils/zip';
@@ -46,26 +48,6 @@ const getTelegramFileInfo = async (
return fileInfo; return fileInfo;
}; };
/**
* Builds a Telegram CDN download URL from a file path and bot token.
*
* @param filePath - The Telegram file path returned by getFile.
* @param botToken - The bot token used to authenticate the download.
* @returns The full Telegram CDN URL.
*/
const buildTelegramFileUrl = (filePath: string, botToken: string): string =>
`https://api.telegram.org/file/bot${botToken}/${filePath}`;
/**
* Sanitises a file name for use in a Content-Disposition header, removing
* characters that could enable header injection.
*
* @param fileName - The raw file name.
* @returns The sanitised file name.
*/
const sanitizeFilenameHeader = (fileName: string): string =>
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
/** /**
* Returns a JSON error response with the given status code and message. * Returns a JSON error response with the given status code and message.
* *
-370
View File
@@ -1,370 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FileDrop · S3 File Manager</title>
<style>
:root {
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
--text2: #666; --border: #e0e0e0; --accent: #2563eb;
--danger: #dc2626; --radius: 8px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117; --bg2: #161b22; --text: #c9d1d9;
--text2: #8b949e; --border: #30363d; --accent: #58a6ff;
--danger: #f85149;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
min-height: 100vh;
}
.topbar {
display: flex; align-items: center; gap: 12px;
padding: 12px 24px; background: var(--bg2);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 50;
}
.topbar .logo { font-weight: 700; font-size: 1.1rem; }
.topbar select, .topbar button {
padding: 6px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
color: var(--text); font-size: 0.875rem; cursor: pointer;
}
.modal input {
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
color: var(--text); margin-bottom: 12px;
}
.topbar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.topbar .spacer { flex: 1; }
.topbar .search input {
padding: 6px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg);
color: var(--text); font-size: 0.875rem; width: 200px;
}
.file-list { padding: 16px 24px; }
.breadcrumb {
padding: 8px 0; margin-bottom: 8px; font-size: 0.9rem;
color: var(--accent); cursor: pointer;
}
.breadcrumb span:hover { text-decoration: underline; }
.breadcrumb .sep { color: var(--text2); margin: 0 4px; }
.file-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: var(--radius);
cursor: pointer; transition: background 0.1s;
}
.file-row:hover { background: var(--bg2); }
.file-row .icon { font-size: 1.2rem; width: 28px; text-align: center; flex-shrink: 0; }
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { width: 80px; text-align: right; color: var(--text2); font-size: 0.85rem; }
.file-row .date { width: 140px; color: var(--text2); font-size: 0.85rem; }
.file-row .actions { display: flex; gap: 4px; }
.file-row .actions button {
padding: 4px 8px; border: none; border-radius: 4px;
background: transparent; color: var(--text2); cursor: pointer; font-size: 0.8rem;
}
.file-row .actions button:hover { color: var(--text); background: var(--border); }
.dropzone {
position: fixed; bottom: 0; left: 0; right: 0;
padding: 12px 24px; background: var(--bg2);
border-top: 1px solid var(--border);
text-align: center; color: var(--text2); font-size: 0.85rem; cursor: pointer;
}
.dropzone.dragover { background: var(--accent); color: #fff; }
.progress-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center; z-index: 100;
}
.progress-card {
background: var(--bg); padding: 24px; border-radius: var(--radius);
min-width: 300px; max-width: 500px;
}
.progress-bar {
height: 8px; background: var(--border); border-radius: 4px;
margin: 12px 0; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: var(--accent);
transition: width 0.2s; width: 0%;
}
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center; z-index: 100;
}
.modal {
background: var(--bg); padding: 24px; border-radius: var(--radius);
min-width: 360px; max-width: 500px;
}
.modal h3 { margin-bottom: 16px; }
.modal .buttons { display: flex; gap: 8px; justify-content: flex-end; }
.modal .buttons button {
padding: 8px 16px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg); color: var(--text); cursor: pointer;
}
.modal .buttons .primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
.empty h2 { font-size: 1.2rem; margin-bottom: 8px; }
.auth-screen {
position: fixed; inset: 0; z-index: 200; display: none;
align-items: center; justify-content: center; padding: 24px;
background: linear-gradient(135deg, var(--bg), var(--bg2));
}
.auth-card {
width: min(100%, 380px); padding: 28px; border: 1px solid var(--border);
border-radius: 16px; background: var(--bg); box-shadow: 0 20px 60px rgba(0,0,0,0.18);
}
.auth-card h1 { font-size: 1.45rem; margin-bottom: 8px; }
.auth-card p { color: var(--text2); margin-bottom: 18px; }
.auth-card input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg2); color: var(--text);
margin-bottom: 12px;
}
.auth-card button {
width: 100%; padding: 10px 14px; border: 1px solid var(--accent);
border-radius: var(--radius); background: var(--accent); color: #fff;
cursor: pointer; font-weight: 600;
}
.auth-card button:disabled { opacity: 0.7; cursor: wait; }
.auth-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 12px; }
.readonly-badge {
font-size: 0.75rem; color: var(--text2); background: var(--bg2);
border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px;
}
</style>
</head>
<body>
<div id="authScreen" class="auth-screen">
<div class="auth-card">
<h1>📦 FileDrop</h1>
<p>Enter admin token to continue.</p>
<input id="authTokenInput" type="password" placeholder="Admin token" autocomplete="current-password">
<div id="authError" class="auth-error" style="display:none"></div>
<button id="authLoginBtn" type="button">Login</button>
</div>
</div>
<div class="topbar">
<span class="logo">📦 FileDrop</span>
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
<option value="">— Select bucket —</option>
</select>
<button id="newBucketBtn" type="button" onclick="window.showCreateBucketModal()">+ New</button>
<button id="credsBtn" type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
<button id="loginBtn" type="button" onclick="window.showAuthScreen()" style="display:none">Login</button>
<button id="logoutBtn" type="button" onclick="window.logout()" style="display:none">Logout</button>
<span id="readonlyBadge" class="readonly-badge" style="display:none">👀 read-only</span>
<span class="spacer"></span>
<div class="search">
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
</div>
</div>
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
<div id="fileList" class="file-list">
<div class="empty"><h2>Select a bucket to get started</h2><p>Choose a bucket from the dropdown above, or create a new one.</p></div>
</div>
<div id="dropzone" class="dropzone" style="display:none">📁 Drop files here or click to upload</div>
<div id="progressOverlay" class="progress-overlay" style="display:none">
<div class="progress-card">
<h3>Uploading...</h3>
<div id="progressFileName"></div>
<div class="progress-bar"><div id="progressFill" class="fill"></div></div>
<div id="progressPercent" style="font-size:0.85rem;color:var(--text2)">0%</div>
</div>
</div>
<div id="modalOverlay" class="modal-overlay" style="display:none" onclick="closeModal(event)">
<div id="modalContent" class="modal" onclick="event.stopPropagation()"></div>
</div>
<script>
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
let isAdmin = false;
const setAuthError = (message) => {
const errorEl = document.getElementById('authError');
errorEl.textContent = message;
errorEl.style.display = message ? 'block' : 'none';
};
const showAuthScreen = () => {
document.getElementById('authScreen').style.display = 'flex';
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
};
const hideAuthScreen = () => {
document.getElementById('authScreen').style.display = 'none';
};
// Applies the admin/read-only UI state based on isAdmin.
const applyAdminUI = () => {
document.getElementById('newBucketBtn').style.display = isAdmin ? 'inline-block' : 'none';
document.getElementById('credsBtn').style.display = isAdmin ? 'inline-block' : 'none';
document.getElementById('loginBtn').style.display = isAdmin ? 'none' : 'inline-block';
document.getElementById('logoutBtn').style.display = isAdmin ? 'inline-block' : 'none';
document.getElementById('readonlyBadge').style.display = isAdmin ? 'none' : 'inline-block';
// Dropzone (upload) is admin-only.
document.getElementById('dropzone').style.display = isAdmin && currentBucket ? 'block' : 'none';
if (currentObjects.length || currentPrefixes.length) renderFileList();
};
// Non-blocking auth check: read-only visitors still get the file browser.
const checkAuth = async () => {
try {
const res = await fetch('/api/v1/auth/me');
if (res.ok) { isAdmin = true; }
else if (res.status === 401) { isAdmin = false; }
else if (res.status === 404) { isAdmin = true; } // auth disabled — full access
else { isAdmin = false; }
} catch {
isAdmin = false;
}
hideAuthScreen();
applyAdminUI();
return isAdmin;
};
const handleLogin = async () => {
const input = document.getElementById('authTokenInput');
const btn = document.getElementById('authLoginBtn');
const token = input.value.trim();
if (!token) { setAuthError('Admin token is required.'); input.focus(); return; }
btn.disabled = true; btn.textContent = 'Logging in...'; setAuthError('');
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
});
if (res.ok) { isAdmin = true; hideAuthScreen(); input.value = ''; applyAdminUI(); await loadBuckets(); return; }
const body = await res.json().catch(() => ({ error: 'Login failed' }));
setAuthError(body.error || 'Login failed');
} catch {
setAuthError('Network error while logging in.');
} finally {
btn.disabled = false; btn.textContent = 'Login';
}
};
const logout = async () => {
await fetch('/api/v1/auth/logout', { method: 'POST' }).catch(() => {});
isAdmin = false;
applyAdminUI();
};
const api = async (path, opts = {}) => {
const res = await fetch(path, opts);
if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || res.statusText); }
return res;
};
const apiJson = async (path, opts = {}) => { const res = await api(path, { headers: { 'content-type': 'application/json' }, ...opts }); return res.json(); };
const loadBuckets = async () => {
const data = await apiJson('/api/v1/buckets');
allBuckets = data.buckets || [];
const sel = document.getElementById('bucketSelect');
sel.innerHTML = `<option value="">— Select bucket —</option>${allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('')}`;
if (currentBucket) sel.value = currentBucket;
};
const switchBucket = async (name) => {
currentBucket = name || null; currentPrefix = '';
if (name) { await loadObjects(); }
else {
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Select a bucket</h2><p>Choose a bucket from the dropdown above.</p></div>';
document.getElementById('breadcrumb').style.display = 'none';
}
// Dropzone (upload) is admin-only.
document.getElementById('dropzone').style.display = isAdmin && currentBucket ? 'block' : 'none';
};
const renderBreadcrumb = () => {
const bc = document.getElementById('breadcrumb');
if (!currentPrefix) { bc.style.display = 'none'; return; }
bc.style.display = 'block';
const parts = currentPrefix.split('/').filter(Boolean);
bc.innerHTML = `<span onclick="window.navigateTo('')">${currentBucket}</span>`;
let accumulated = '';
for (const part of parts) { accumulated += `${part}/`; bc.innerHTML += `<span class="sep">/</span><span onclick="window.navigateTo('${accumulated}')">${part}</span>`; }
};
const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
const loadObjects = async () => {
if (!currentBucket) return;
const searchVal = document.getElementById('searchInput').value;
const prefix = searchVal || currentPrefix;
const url = `/api/v1/buckets/${encodeURIComponent(currentBucket)}/objects?prefix=${encodeURIComponent(prefix)}&delimiter=/&max-keys=200`;
try {
const data = await apiJson(url);
currentObjects = data.objects || []; currentPrefixes = data.prefixes || [];
renderFileList(); renderBreadcrumb();
} catch (e) { document.getElementById('fileList').innerHTML = `<div class="empty"><h2>Error</h2><p>${e.message}</p></div>`; }
};
const renderFileList = () => {
const container = document.getElementById('fileList');
if (currentPrefixes.length === 0 && currentObjects.length === 0) {
container.innerHTML = isAdmin
? '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'
: '<div class="empty"><h2>This bucket is empty</h2></div>';
return;
}
let html = '';
for (const prefix of currentPrefixes) {
const displayName = prefix.replace(currentPrefix, '');
html += `<div class="file-row" onclick="window.navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : `${displayName}/`}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
}
for (const obj of currentObjects) {
const displayName = obj.key.replace(currentPrefix, '');
// Delete is admin-only; download + copy link are always available.
const deleteBtn = isAdmin ? `<button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button>` : '';
html += `<div class="file-row"><span class="icon">📄</span><span class="name">${escapeHtml(displayName)}</span><span class="size">${formatSize(obj.sizeBytes)}</span><span class="date">${formatDate(obj.lastModified)}</span><span class="actions"><button onclick="event.stopPropagation();downloadObject('${obj.key}')" title="Download">⬇</button><button onclick="event.stopPropagation();copyLink('${obj.key}')" title="Copy link">🔗</button>${deleteBtn}</span></div>`;
}
container.innerHTML = html;
};
const formatSize = (bytes) => { const size = Number(bytes); if (!Number.isFinite(size) || size <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=size; while(s>=1024&&i<u.length-1){s/=1024;i++} return `${s.toFixed(i>0?1:0)} ${u[i]}`; };
const formatDate = (iso) => { if(!iso)return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); };
const escapeHtml = (s) => { const d=document.createElement('div');d.textContent=s;return d.innerHTML; };
const debouncedSearch = () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadObjects, 300); };
const downloadObject = async (key) => { window.open(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`,'_blank'); };
const copyLink = (key) => { navigator.clipboard.writeText(`${window.location.origin}/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`).catch(()=>{}); };
const deleteObject = async (key) => {
if (!isAdmin) { alert('Read-only mode — login as admin to delete.'); return; }
if(!confirm(`Delete "${key}"?`))return;
try{await api(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/${encodeURIComponent(key)}`,{method:'DELETE'});await loadObjects();}
catch(e){alert(`Delete failed: ${e.message}`);}
};
const uploadFiles = async (files) => {
if (!isAdmin) { alert('Read-only mode — login as admin to upload.'); return; }
if(!currentBucket||files.length===0)return;
const overlay=document.getElementById('progressOverlay'), fill=document.getElementById('progressFill'), pn=document.getElementById('progressFileName'), pp=document.getElementById('progressPercent');
overlay.style.display='flex';
for(let i=0;i<files.length;i++){
const file=files[i]; pn.textContent=`${i+1}/${files.length}: ${file.name}`; fill.style.width='0%'; pp.textContent='0%';
await new Promise((resolve,reject)=>{
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=`${p}%`;pp.textContent=`${p}%`;}};
xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
xhr.onerror=()=>reject(new Error('Upload failed'));
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
});
}
overlay.style.display='none'; await loadObjects();
};
const dropzone=document.getElementById('dropzone');
dropzone.addEventListener('dragover',e=>{e.preventDefault();dropzone.classList.add('dragover');});
dropzone.addEventListener('dragleave',()=>dropzone.classList.remove('dragover'));
dropzone.addEventListener('drop',e=>{e.preventDefault();dropzone.classList.remove('dragover');if(e.dataTransfer.files.length>0)uploadFiles(e.dataTransfer.files);});
dropzone.addEventListener('click',()=>{const i=document.createElement('input');i.type='file';i.multiple=true;i.onchange=()=>{if(i.files.length>0)uploadFiles(i.files);};i.click();});
const showModal=(html)=>{document.getElementById('modalContent').innerHTML=html;document.getElementById('modalOverlay').style.display='flex';};
const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';};
const showCreateBucketModal=()=>{
if (!isAdmin) { alert('Read-only mode — login as admin to create buckets.'); return; }
showModal(`<h3>Create Bucket</h3><input id="bucketNameInput" type="text" placeholder="my-bucket-name" pattern="[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]"><p style="font-size:0.8rem;color:var(--text2);margin-bottom:12px">Lowercase, 3-63 chars, no underscores</p><div class="buttons"><button onclick="closeModal()">Cancel</button><button class="primary" onclick="createBucket()">Create</button></div>`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);};
const createBucket=async()=>{
if (!isAdmin) { alert('Read-only mode — login as admin to create buckets.'); return; }
const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}};
const showCredentialsModal=()=>{
if (!isAdmin) { alert('Read-only mode — login as admin to view S3 credentials.'); return; }
showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weightLine truncated
const init=async()=>{await checkAuth();await loadBuckets();};
document.getElementById('authLoginBtn').addEventListener('click',handleLogin);
document.getElementById('authTokenInput').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin();});
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal, showAuthScreen, logout });
init();
</script>
</body>
</html>
+4 -30
View File
@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac } from 'node:crypto';
import { config } from '../../../env'; import { config } from '../../../env';
import { timingSafeCompare } from '../../../shared/utils/crypto';
const ADMIN_USERNAME = 'admin'; const ADMIN_USERNAME = 'admin';
const SIGNATURE_SEPARATOR = '.'; const SIGNATURE_SEPARATOR = '.';
@@ -11,17 +12,7 @@ type Handler = (req: Request) => Response | Promise<Response>;
* Represents an authenticated user session after successful * Represents an authenticated user session after successful
* authentication via cookie or bearer token. * authentication via cookie or bearer token.
*/ */
export interface AuthSession { export type { AuthSession } from '../../../application/dto/auth';
/** The authenticated username (always "admin" in this implementation). */
username: string;
/**
* Expiration date of the session, or `null` for bearer-token
* sessions which do not expire at the session level.
*/
expiresAt: Date | null;
/** The authentication method used to establish this session. */
method: 'cookie' | 'bearer';
}
/** Options for configuring cookie-based session behaviour. */ /** Options for configuring cookie-based session behaviour. */
interface CookieOptions { interface CookieOptions {
@@ -64,24 +55,7 @@ const decodePayload = (value: string): string | null => {
*/ */
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0; export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
/** export { timingSafeCompare } from '../../../shared/utils/crypto';
* Compares two strings using a timing-safe algorithm to prevent
* timing side-channel attacks.
*
* @param left - First string to compare.
* @param right - Second string to compare.
* @returns `true` when the strings are equal, `false` otherwise.
*/
export const timingSafeCompare = (left: string, right: string): boolean => {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
};
/** /**
* Signs an arbitrary payload string with HMAC-SHA256 using the given * Signs an arbitrary payload string with HMAC-SHA256 using the given
+1 -23
View File
@@ -1,26 +1,4 @@
import { timingSafeEqual } from 'node:crypto'; import { timingSafeCompare } from '../../shared/utils/crypto';
/**
* Timing-safe string comparison that prevents timing attacks.
*
* Uses `crypto.timingSafeEqual` which runs in constant time regardless of
* where the strings differ. Returns false for mismatched-length inputs
* to avoid leaking length information via early return.
*
* @param left - The first string to compare.
* @param right - The second string to compare.
* @returns True if both strings are equal.
*/
const timingSafeCompare = (left: string, right: string): boolean => {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
};
export interface SigV4Result { export interface SigV4Result {
isValid: boolean; isValid: boolean;
+13
View File
@@ -0,0 +1,13 @@
/**
* Sanitises a file name for use in a Content-Disposition header, removing
* characters that could enable header injection.
*
* Single canonical implementation — previously only present in
* `interfaces/http/controllers/file-controller.ts` while other download
* paths (S3 GET, web-api) did not sanitise at all.
*
* @param fileName - The raw file name.
* @returns The sanitised file name.
*/
export const sanitizeFilenameHeader = (fileName: string): string =>
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
+24
View File
@@ -0,0 +1,24 @@
import { timingSafeEqual } from 'node:crypto';
/**
* Compares two strings using a timing-safe algorithm to prevent
* timing side-channel attacks.
*
* Single canonical implementation — replaces the three copies that
* previously lived in `interfaces/http/middleware/auth.ts`,
* `application/use-cases/authenticate.ts`, and `interfaces/s3/auth.ts`.
*
* @param left - First string to compare.
* @param right - Second string to compare.
* @returns `true` when the strings are equal, `false` otherwise.
*/
export const timingSafeCompare = (left: string, right: string): boolean => {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
};
+161
View File
@@ -0,0 +1,161 @@
import { z } from 'zod';
/**
* Boundary validation schemas (zod) for all system entry points.
*
* Every HTTP/S3/bot boundary parses untrusted input through one of these
* schemas before touching domain logic. Controllers map a failed parse to
* the transport-appropriate error (400 JSON / S3 XML error) — see
* `parseOrNull` usage at each call site.
*
* @module shared/validation/schemas
*/
/**
* S3 bucket name — the single canonical validator.
*
* Replaces three divergent variants: the inline regex in s3-controller
* (`handleCreateBucket`), `BUCKET_NAME_REGEX` in
* `application/use-cases/manage-bucket.ts`, and `isValidBucketLabel` in
* `interfaces/s3/virtual-host.ts`.
*
* Rules: 3–63 chars, lowercase letters/digits/dots/hyphens, start/end with
* letter-or-digit, plus the stricter M13 rules (no consecutive dots, no IP
* format, no `xn--` prefix).
*/
export const BucketNameSchema = z
.string()
.min(3)
.max(63)
.regex(/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/, 'Bucket name has invalid format')
.refine((name) => !name.includes('..'), 'Bucket name must not contain consecutive dots')
.refine(
(name) => !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name),
'Bucket name must not be formatted as an IP address',
)
.refine((name) => !name.startsWith('xn--'), 'Bucket name must not start with "xn--"');
/** Inferred bucket-name type. */
export type BucketName = z.infer<typeof BucketNameSchema>;
/**
* JSON upload endpoint body (`POST /api/upload` with
* `Content-Type: application/json`).
*
* Replaces the bare `as JsonUploadPayload` cast in
* `interfaces/http/controllers/upload-controller.ts`.
*/
export const JsonUploadPayloadSchema = z.object({
/** Base64-encoded file data, optionally with a `data:` URI prefix. */
file: z.string().min(1, 'Invalid JSON. Must include "file" (base64) and optional "fileName"'),
/** Optional file name. */
fileName: z.string().default('file'),
});
/** Inferred JSON-upload payload type. */
export type JsonUploadPayload = z.infer<typeof JsonUploadPayloadSchema>;
/**
* Login endpoint body (`POST /api/v1/auth/login`).
*
* Replaces the manual `typeof token` check in `readLoginBody`
* (`interfaces/http/controllers/auth-controller.ts`).
*/
export const LoginBodySchema = z.object({
/** Admin API token. */
token: z.string().min(1, 'Token is required'),
});
/** Inferred login-body type. */
export type LoginBody = z.infer<typeof LoginBodySchema>;
/** A single key entry in an S3 DeleteObjects (`?delete`) XML body. */
export const DeleteObjectKeySchema = z.string().min(1);
/**
* S3 multi-object delete body (`POST /{bucket}?delete`).
*
* Validates the *parsed* output of `parseDeleteObjectsBody` (`interfaces/s3/xml.ts`).
* The regex parser is kept (low-risk), its output is validated here — including
* the M11 S3 limit of 1000 keys per request.
*/
export const DeleteObjectsBodySchema = z.object({
/** Object keys to delete. */
keys: z.array(DeleteObjectKeySchema).max(1000, 'Max 1000 keys per request'),
/** Quiet mode — return only errors. */
quiet: z.boolean(),
});
/** Inferred delete-objects body type. */
export type DeleteObjectsBody = z.infer<typeof DeleteObjectsBodySchema>;
/** A single part entry in a CompleteMultipartUpload XML body. */
export const CompletePartSchema = z.object({
/** 1-based part number (1–10000 per S3 spec). */
partNumber: z.number().int().min(1).max(10000),
/** Part ETag as returned by UploadPart (quotes already stripped by the parser). */
etag: z.string().min(1),
});
/**
* S3 CompleteMultipartUpload body (`POST /{bucket}/{key}?uploadId=`).
*
* Validates the *parsed* output of `parseCompleteMultipartBody`
* (`interfaces/s3/xml.ts`).
*/
export const CompleteMultipartBodySchema = z.object({
/** Parts in the order submitted by the client. */
parts: z.array(CompletePartSchema),
});
/** Inferred complete-multipart body type. */
export type CompleteMultipartBody = z.infer<typeof CompleteMultipartBodySchema>;
/**
* Shared clamp for paginated S3 listing query params (`max-keys`,
* `max-uploads`, `max-parts`).
*
* Replaces four divergent inline clamps (ListObjectsV1 used `Math.max(1, …)`,
* V2/ListUploads/ListParts used bare `Math.min(…, 1000)` which admitted 0,
* negatives and NaN). Garbage input now falls back to the S3 default of 1000.
*
* @param raw - Raw query-param value (may be null when absent).
* @param fallback - Default when the value is missing or invalid (default 1000).
* @returns An integer in [1, 1000].
*/
export const clampMaxKeys = (raw: string | null, fallback = 1000): number => {
const parsed = z.coerce.number().int().min(1).max(1000).safeParse(raw);
if (parsed.success) return parsed.data;
if (raw === null) return fallback;
return 1000;
};
/**
* S3 part number from `?partNumber=` (UploadPart).
*
* Mirrors the M14 inline check in `handleUploadPart` (integer 1–10000).
*/
export const PartNumberSchema = z.coerce.number().int().min(1).max(10000);
/**
* Strictly positive integer coercion for numeric env config (e.g. `PORT`).
*
* Unlike the old `parseNumber` fallback in `src/env.ts` (garbage → silent
* default), invalid values fail so startup fails fast instead of running
* misconfigured.
*/
export const PositiveIntSchema = z.coerce.number().int().positive();
/**
* Parses unknown input with a zod schema, returning the typed value or
* `null` on failure. Use at transport boundaries where the caller maps
* failure to its own error shape (JSON 400 vs S3 XML error).
*
* @param schema - The zod schema to parse with.
* @param input - Untrusted input.
* @returns The parsed value, or `null` when invalid.
*/
export const parseOrNull = <T>(schema: z.ZodType<T>, input: unknown): T | null => {
const result = schema.safeParse(input);
return result.success ? result.data : null;
};
+17 -4
View File
@@ -42,7 +42,7 @@ mock.module('../src/interfaces/bot/handler', () => ({
startBot: mockStartBot, startBot: mockStartBot,
})); }));
mock.module('../src/db/migrate', () => ({ mock.module('../src/infrastructure/persistence/drizzle/migrate', () => ({
runMigration: mock(() => Promise.resolve()), runMigration: mock(() => Promise.resolve()),
})); }));
@@ -75,7 +75,7 @@ mock.module('../src/interfaces/http/middleware/auth', () => ({
requireAuth: mockRequireAuth, requireAuth: mockRequireAuth,
})); }));
mock.module('../src/utils/rateLimit', () => ({ mock.module('../src/interfaces/http/middleware/rate-limit', () => ({
cleanupRateLimitCache: mock(), cleanupRateLimitCache: mock(),
clearRateLimitCache: mock(), clearRateLimitCache: mock(),
checkRateLimit: mock(() => true), checkRateLimit: mock(() => true),
@@ -128,8 +128,21 @@ describe('Bootstrap Server', () => {
expect(await res.json()).toEqual({ ok: true }); expect(await res.json()).toEqual({ ok: true });
expect(mockHandleUpload).toHaveBeenCalledTimes(1); expect(mockHandleUpload).toHaveBeenCalledTimes(1);
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as { GET: RouteHandler }; // GET /api/v1/* is intentionally public (read endpoints need no auth) —
const protectedRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files')); // it passes through to the raw handler (stubbed here to 404).
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as {
GET: RouteHandler;
POST: RouteHandler;
};
const publicRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
expect(publicRes.status).toBe(404);
expect(await publicRes.json()).toEqual({ error: 'Not Found' });
// Write endpoints are auth-guarded — POST goes through requireAuth (401 here).
const protectedRes = await webApiRoute.POST(
new Request('http://localhost/api/v1/files', { method: 'POST' }),
);
expect(protectedRes.status).toBe(401); expect(protectedRes.status).toBe(401);
expect(await protectedRes.json()).toEqual({ error: 'Unauthorized' }); expect(await protectedRes.json()).toEqual({ error: 'Unauthorized' });
-3
View File
@@ -7,9 +7,6 @@ import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
* Tests the real ChunkedStorage class (src/infrastructure/telegram/ * Tests the real ChunkedStorage class (src/infrastructure/telegram/
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected * chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
* telegramService, so we stub that and pass no-op repos for the rest. * telegramService, so we stub that and pass no-op repos for the rest.
*
* Rewritten from a stale test that imported the old `src/utils/chunked-storage`
* layout, which no longer exists after the refactor.
*/ */
const makeTelegramStub = (): ITelegramService => const makeTelegramStub = (): ITelegramService =>
({ ({
+1 -1
View File
@@ -24,7 +24,7 @@ describe('Health Route Handler', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body).toEqual({ status: 'ok' }); expect(body).toEqual({ status: 'ok', version: '1.2.2' });
expect(mockExecute).toHaveBeenCalled(); expect(mockExecute).toHaveBeenCalled();
}); });
+170
View File
@@ -0,0 +1,170 @@
import { mock } from 'bun:test';
import type { ITelegramService } from '../../src/domain/ports/telegram-service';
/**
* Shared test doubles for the TeleUploader unit test suite.
*
* Consolidates the mock patterns that were previously copy-pasted across
* test files (`MockTelegraf` in bot-pool/bot/telegram tests, stub DI repos
* in upload/files tests, deterministic nanoid counters in upload tests).
*
* Rules:
* - Import from this module inside individual test files only — never as a
* global preload (avoids cross-file mock pollution; see CLAUDE.md).
* - Prefer `mock.module` with these factories over hand-rolled inline mocks
* so behavior stays consistent when the doubles evolve.
*
* @module test/helpers/test-doubles
*/
/** Minimal Telegram message result shape used by the MockTelegraf doubles. */
export interface MockTelegramMessage {
/** Telegram message identifier. */
message_id: number;
/** Document payload (present for document uploads). */
document?: { file_id: string; file_unique_id: string };
/** Photo payload (present for photo uploads). */
photo?: Array<{ file_id: string; file_unique_id: string }>;
}
/**
* Creates a Telegraf mock payload (`mock.module('telegraf', …)` factory).
*
* Mirrors the `MockTelegraf` pattern from `bot-pool.test.ts` / `bot.test.ts` /
* `telegram.test.ts` with controllable per-method implementations.
*
* @param overrides - Optional per-method implementations.
* @returns A `{ Telegraf }` module shape for `mock.module`.
*/
export const mockTelegrafModule = (overrides?: {
sendDocument?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
sendPhoto?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
getFile?: (fileId: string) => Promise<{ file_path?: string }>;
}) => {
const sendDocument =
overrides?.sendDocument ??
(async () => ({
message_id: 54321,
document: { file_id: 'document_id', file_unique_id: 'document_unique_id' },
}));
const sendPhoto =
overrides?.sendPhoto ??
(async () => ({
message_id: 12345,
photo: [
{ file_id: 'photo_id_low', file_unique_id: 'unique_id_low' },
{ file_id: 'photo_id_high', file_unique_id: 'unique_id_high' },
],
}));
const getFile = overrides?.getFile ?? (async () => ({ file_path: 'documents/file.dat' }));
return {
Telegraf: class {
token: unknown;
telegram: {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
getFile: typeof getFile;
};
constructor(token: unknown) {
this.token = token;
this.telegram = { sendDocument, sendPhoto, getFile };
}
},
};
};
/**
* Creates a minimal {@link ITelegramService} stub backed by `bun:test` mocks.
*
* Mirrors the hand-rolled stub in `chunked-storage.test.ts` (real
* `ChunkedStorage` + stub telegram service + no-op repos).
*
* @param overrides - Optional per-method implementations.
* @returns An `ITelegramService` implementation whose methods are mocks.
*/
export const makeTelegramServiceStub = (
overrides?: Partial<ITelegramService>,
): ITelegramService & {
forwardToStorage: ReturnType<typeof mock>;
getFileInfo: ReturnType<typeof mock>;
} => {
const forwardToStorage = mock(
overrides?.forwardToStorage ??
(async (_bytes: unknown, fileName: string) => ({
telegramFileId: `tg-${fileName}`,
telegramFileUniqueId: `tg-unique-${fileName}`,
storageMessageId: 1,
})),
);
const getFileInfo = mock(
overrides?.getFileInfo ??
(async (telegramFileId: string) => ({
file_size: 100,
mime_type: 'application/octet-stream',
file_path: 'documents/file.dat',
bot_token: '123456:ABC-DEF',
telegramFileId,
})),
);
return {
forwardToStorage: forwardToStorage as unknown as ITelegramService['forwardToStorage'],
getFileInfo: getFileInfo as unknown as ITelegramService['getFileInfo'],
};
};
/**
* Creates a deterministic `nanoid` module mock — each call returns
* `prefix-1`, `prefix-2`, … instead of random IDs.
*
* Replaces the ad-hoc `mock.module('nanoid')` counters in `upload.test.ts`.
*
* @param prefix - Prefix for generated IDs (default `"test-id"`).
* @returns A `{ nanoid }` module shape for `mock.module`.
*/
export const mockNanoidModule = (prefix = 'test-id') => {
let counter = 0;
return {
nanoid: mock(() => `${prefix}-${++counter}`),
};
};
/**
* Builds a signed S3 test request with valid SigV4 headers.
*
* Request-signing is covered by `s3-auth.test.ts`; this helper exists for
* handler-level tests that mock `../src/interfaces/s3/auth` to accept any
* signature and only need a well-formed request object.
*
* @param url - Request URL (path-style `/bucket/key` or `/`).
* @param init - Optional `RequestInit` overrides (method defaults to GET).
* @returns A `Request` with stub SigV4 headers.
*/
export const s3TestRequest = (url: string, init?: RequestInit): Request =>
new Request(url, {
method: 'GET',
...init,
headers: {
authorization: 'AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/s3/aws4_request',
'x-amz-date': '20260101T000000Z',
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
...(init?.headers ?? {}),
},
});
/** 1x1px JPEG fallback binary (offline-safe fixture, no network fetch). */
export const TINY_JPEG_HEX =
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0b000080100010101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9';
/**
* Returns the tiny-JPEG fixture as a `Buffer` without any network access.
*
* Replaces the Wikimedia-fetch-with-fallback pattern in `telegram.test.ts`
* and `upload.test.ts` for tests that only need *some* valid image bytes.
* Tests that genuinely need a real PNG must stay in quarantine (see
* `test:quarantine` in package.json).
*
* @returns A 1x1px JPEG buffer.
*/
export const tinyJpegBuffer = (): Buffer => Buffer.from(TINY_JPEG_HEX, 'hex');
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'bun:test';
import {
BucketNameSchema,
CompleteMultipartBodySchema,
clampMaxKeys,
DeleteObjectsBodySchema,
JsonUploadPayloadSchema,
LoginBodySchema,
parseOrNull,
} from '../src/shared/validation/schemas';
describe('BucketNameSchema (single canonical validator)', () => {
it('accepts valid bucket names', () => {
expect(BucketNameSchema.safeParse('gitea').success).toBe(true);
expect(BucketNameSchema.safeParse('my.bucket-01').success).toBe(true);
});
it('rejects invalid names (format, dots, IP, xn--)', () => {
expect(BucketNameSchema.safeParse('ab').success).toBe(false);
expect(BucketNameSchema.safeParse('UPPERCASE').success).toBe(false);
expect(BucketNameSchema.safeParse('a..b').success).toBe(false);
expect(BucketNameSchema.safeParse('192.168.1.1').success).toBe(false);
expect(BucketNameSchema.safeParse('xn--bcher-kva').success).toBe(false);
});
});
describe('JsonUploadPayloadSchema', () => {
it('accepts file + optional fileName', () => {
expect(
JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=', fileName: 'hi.txt' }).success,
).toBe(true);
});
it('defaults fileName and rejects missing/empty file', () => {
const parsed = JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=' });
expect(parsed.success && parsed.data.fileName).toBe('file');
expect(JsonUploadPayloadSchema.safeParse({}).success).toBe(false);
expect(JsonUploadPayloadSchema.safeParse({ file: '' }).success).toBe(false);
});
});
describe('LoginBodySchema', () => {
it('accepts a non-empty token and rejects the rest', () => {
expect(LoginBodySchema.safeParse({ token: 'secret' }).success).toBe(true);
expect(LoginBodySchema.safeParse({}).success).toBe(false);
expect(LoginBodySchema.safeParse({ token: '' }).success).toBe(false);
expect(LoginBodySchema.safeParse({ token: 42 }).success).toBe(false);
});
});
describe('DeleteObjectsBodySchema', () => {
it('accepts parsed keys and enforces the 1000-key S3 limit', () => {
expect(DeleteObjectsBodySchema.safeParse({ keys: ['a', 'b'], quiet: false }).success).toBe(
true,
);
expect(
DeleteObjectsBodySchema.safeParse({ keys: new Array(1001).fill('k'), quiet: true }).success,
).toBe(false);
});
});
describe('CompleteMultipartBodySchema', () => {
it('accepts parsed parts and rejects bad part numbers', () => {
expect(
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 1, etag: 'abc' }] }).success,
).toBe(true);
expect(
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 0, etag: 'abc' }] }).success,
).toBe(false);
});
});
describe('clampMaxKeys (unified listing clamp)', () => {
it('clamps into [1, 1000] and falls back to 1000 on garbage', () => {
expect(clampMaxKeys(null)).toBe(1000);
expect(clampMaxKeys('5')).toBe(5);
expect(clampMaxKeys('99999')).toBe(1000);
expect(clampMaxKeys('0')).toBe(1000);
expect(clampMaxKeys('-3')).toBe(1000);
expect(clampMaxKeys('abc')).toBe(1000);
});
});
describe('parseOrNull', () => {
it('returns the value on success and null on failure', () => {
expect(parseOrNull(LoginBodySchema, { token: 'x' })).toEqual({ token: 'x' });
expect(parseOrNull(LoginBodySchema, {})).toBeNull();
});
});