diff --git a/bun.lock b/bun.lock
index db64349..c36ae0f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"postgres": "^3.4.9",
"telegraf": "^4.15.0",
"winston": "^3.11.0",
+ "zod": "^4.6.5",
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.1079.0",
@@ -357,6 +358,8 @@
"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-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"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=="],
diff --git a/package.json b/package.json
index 8836a5d..154a26f 100644
--- a/package.json
+++ b/package.json
@@ -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",
"start": "NODE_ENV=production bun dist/index.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 && bun test --preload ./test/helpers/setup-env.ts test/bot.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts test/swagger.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth-routes.test.ts && 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 ./test/helpers/setup-env.ts test/s3-helpers-edge.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-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/chunked-storage.test.ts && bun test --preload ./test/helpers/setup-env.ts test/temp-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/zip.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts && bun test --preload ./test/helpers/setup-env.ts test/env.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.test.ts",
+ "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.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts test/swagger.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth-routes.test.ts && bun test --preload ./test/helpers/setup-env.ts test/validation.test.ts && 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 ./test/helpers/setup-env.ts test/s3-helpers-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/chunked-storage.test.ts && bun test --preload ./test/helpers/setup-env.ts test/temp-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/zip.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts && bun test --preload ./test/helpers/setup-env.ts test/env.test.ts && bun test --preload ./test/helpers/setup-env.ts test/home.test.ts && bun test --preload ./test/helpers/setup-env.ts test/deploy-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/public-readonly-routes.test.ts",
+ "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-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: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 ./test/helpers/setup-env.ts test/s3-helpers-edge.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-bucket-config.test.ts && 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 ./test/helpers/setup-env.ts test/s3-helpers-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
"lint": "bunx biome check src test",
"format": "bunx biome format --write src test",
"prepare": "husky"
@@ -25,7 +27,8 @@
"pg": "^8.11.0",
"postgres": "^3.4.9",
"telegraf": "^4.15.0",
- "winston": "^3.11.0"
+ "winston": "^3.11.0",
+ "zod": "^4.6.5"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.1079.0",
diff --git a/src/application/use-cases/authenticate.ts b/src/application/use-cases/authenticate.ts
index 27cd6f6..1ac4285 100644
--- a/src/application/use-cases/authenticate.ts
+++ b/src/application/use-cases/authenticate.ts
@@ -1,4 +1,4 @@
-import { timingSafeEqual } from 'node:crypto';
+import { timingSafeCompare } from '../../shared/utils/crypto';
import type {
AuthSession,
LoginInput,
@@ -23,24 +23,6 @@ export interface AuthenticateUseCaseDeps {
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.
*
diff --git a/src/infrastructure/telegram/file-url.ts b/src/infrastructure/telegram/file-url.ts
new file mode 100644
index 0000000..77cc553
--- /dev/null
+++ b/src/infrastructure/telegram/file-url.ts
@@ -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}`;
diff --git a/src/interfaces/http/controllers/file-controller.ts b/src/interfaces/http/controllers/file-controller.ts
index 0e7f830..ff2e7a4 100644
--- a/src/interfaces/http/controllers/file-controller.ts
+++ b/src/interfaces/http/controllers/file-controller.ts
@@ -4,6 +4,8 @@ import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
import { fileInfoCache } from '../../../infrastructure/cache/index';
import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
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 { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
import { locateZipEntry } from '../../../shared/utils/zip';
@@ -46,26 +48,6 @@ const getTelegramFileInfo = async (
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.
*
diff --git a/src/interfaces/http/controllers/home.html b/src/interfaces/http/controllers/home.html
deleted file mode 100644
index 8ddfd0a..0000000
--- a/src/interfaces/http/controllers/home.html
+++ /dev/null
@@ -1,370 +0,0 @@
-
-
-
-
-
- FileDrop ยท S3 File Manager
-
-
-
-
-
-
๐ฆ FileDrop
-
Enter admin token to continue.
-
-
-
-
-
-
- ๐ฆ FileDrop
-
-
-
-
-
- ๐ read-only
-
-
-
-
-
-
-
-
Select a bucket to get started
Choose a bucket from the dropdown above, or create a new one.
-
-
๐ Drop files here or click to upload
-
-
-
Uploading...
-
-
-
0%
-
-
-
-
-
-
-
-
diff --git a/src/interfaces/http/middleware/auth.ts b/src/interfaces/http/middleware/auth.ts
index 23901fd..58dc69e 100644
--- a/src/interfaces/http/middleware/auth.ts
+++ b/src/interfaces/http/middleware/auth.ts
@@ -1,5 +1,6 @@
-import { createHmac, timingSafeEqual } from 'node:crypto';
+import { createHmac } from 'node:crypto';
import { config } from '../../../env';
+import { timingSafeCompare } from '../../../shared/utils/crypto';
const ADMIN_USERNAME = 'admin';
const SIGNATURE_SEPARATOR = '.';
@@ -11,17 +12,7 @@ type Handler = (req: Request) => Response | Promise;
* Represents an authenticated user session after successful
* authentication via cookie or bearer token.
*/
-export interface AuthSession {
- /** 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';
-}
+export type { AuthSession } from '../../../application/dto/auth';
/** Options for configuring cookie-based session behaviour. */
interface CookieOptions {
@@ -64,24 +55,7 @@ const decodePayload = (value: string): string | null => {
*/
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
-/**
- * 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);
-};
+export { timingSafeCompare } from '../../../shared/utils/crypto';
/**
* Signs an arbitrary payload string with HMAC-SHA256 using the given
diff --git a/src/interfaces/s3/auth.ts b/src/interfaces/s3/auth.ts
index 720b3ed..753aadb 100644
--- a/src/interfaces/s3/auth.ts
+++ b/src/interfaces/s3/auth.ts
@@ -1,26 +1,4 @@
-import { timingSafeEqual } from 'node: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);
-};
+import { timingSafeCompare } from '../../shared/utils/crypto';
export interface SigV4Result {
isValid: boolean;
diff --git a/src/shared/http/filename.ts b/src/shared/http/filename.ts
new file mode 100644
index 0000000..a08fb65
--- /dev/null
+++ b/src/shared/http/filename.ts
@@ -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, '');
diff --git a/src/shared/utils/crypto.ts b/src/shared/utils/crypto.ts
new file mode 100644
index 0000000..3546642
--- /dev/null
+++ b/src/shared/utils/crypto.ts
@@ -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);
+};
diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts
new file mode 100644
index 0000000..f080cf9
--- /dev/null
+++ b/src/shared/validation/schemas.ts
@@ -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;
+
+/**
+ * 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;
+
+/**
+ * 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;
+
+/** 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;
+
+/** 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;
+
+/**
+ * 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 = (schema: z.ZodType, input: unknown): T | null => {
+ const result = schema.safeParse(input);
+ return result.success ? result.data : null;
+};
diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts
index 8663dd0..3d695a5 100644
--- a/test/bootstrap.test.ts
+++ b/test/bootstrap.test.ts
@@ -42,7 +42,7 @@ mock.module('../src/interfaces/bot/handler', () => ({
startBot: mockStartBot,
}));
-mock.module('../src/db/migrate', () => ({
+mock.module('../src/infrastructure/persistence/drizzle/migrate', () => ({
runMigration: mock(() => Promise.resolve()),
}));
@@ -75,7 +75,7 @@ mock.module('../src/interfaces/http/middleware/auth', () => ({
requireAuth: mockRequireAuth,
}));
-mock.module('../src/utils/rateLimit', () => ({
+mock.module('../src/interfaces/http/middleware/rate-limit', () => ({
cleanupRateLimitCache: mock(),
clearRateLimitCache: mock(),
checkRateLimit: mock(() => true),
@@ -128,8 +128,21 @@ describe('Bootstrap Server', () => {
expect(await res.json()).toEqual({ ok: true });
expect(mockHandleUpload).toHaveBeenCalledTimes(1);
- const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as { GET: RouteHandler };
- const protectedRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
+ // GET /api/v1/* is intentionally public (read endpoints need no auth) โ
+ // 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(await protectedRes.json()).toEqual({ error: 'Unauthorized' });
diff --git a/test/chunked-storage.test.ts b/test/chunked-storage.test.ts
index 2be4372..d0cc8a5 100644
--- a/test/chunked-storage.test.ts
+++ b/test/chunked-storage.test.ts
@@ -7,9 +7,6 @@ import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
* Tests the real ChunkedStorage class (src/infrastructure/telegram/
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
* 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 =>
({
diff --git a/test/health.test.ts b/test/health.test.ts
index 194a804..afec891 100644
--- a/test/health.test.ts
+++ b/test/health.test.ts
@@ -24,7 +24,7 @@ describe('Health Route Handler', () => {
expect(res.status).toBe(200);
const body = await res.json();
- expect(body).toEqual({ status: 'ok' });
+ expect(body).toEqual({ status: 'ok', version: '1.2.2' });
expect(mockExecute).toHaveBeenCalled();
});
diff --git a/test/helpers/test-doubles.ts b/test/helpers/test-doubles.ts
new file mode 100644
index 0000000..b235a89
--- /dev/null
+++ b/test/helpers/test-doubles.ts
@@ -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;
+ sendPhoto?: (chatId: unknown, file: unknown, extra?: unknown) => Promise;
+ 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 & {
+ forwardToStorage: ReturnType;
+ getFileInfo: ReturnType;
+} => {
+ 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');
diff --git a/test/validation.test.ts b/test/validation.test.ts
new file mode 100644
index 0000000..50f8915
--- /dev/null
+++ b/test/validation.test.ts
@@ -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();
+ });
+});