From 5d01a9405f7b9f38f2f75d98be5171a0bef0f657 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 14 Sep 2026 17:09:52 +0700 Subject: [PATCH] =?UTF-8?q?refactor(fase0):=20shared=20scaffold=20?= =?UTF-8?q?=E2=80=94=20crypto,=20file-url,=20schemas,=20test=20splits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bun.lock | 3 + package.json | 9 +- src/application/use-cases/authenticate.ts | 20 +- src/infrastructure/telegram/file-url.ts | 19 + .../http/controllers/file-controller.ts | 22 +- src/interfaces/http/controllers/home.html | 370 ------------------ src/interfaces/http/middleware/auth.ts | 34 +- src/interfaces/s3/auth.ts | 24 +- src/shared/http/filename.ts | 13 + src/shared/utils/crypto.ts | 24 ++ src/shared/validation/schemas.ts | 161 ++++++++ test/bootstrap.test.ts | 21 +- test/chunked-storage.test.ts | 3 - test/health.test.ts | 2 +- test/helpers/test-doubles.ts | 170 ++++++++ test/validation.test.ts | 89 +++++ 16 files changed, 511 insertions(+), 473 deletions(-) create mode 100644 src/infrastructure/telegram/file-url.ts delete mode 100644 src/interfaces/http/controllers/home.html create mode 100644 src/shared/http/filename.ts create mode 100644 src/shared/utils/crypto.ts create mode 100644 src/shared/validation/schemas.ts create mode 100644 test/helpers/test-doubles.ts create mode 100644 test/validation.test.ts 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.

- - - -
-
-
- - - - - - - - - -
- -
-

Select a bucket to get started

Choose a bucket from the dropdown above, or create a new one.

-
- - - - - - 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(); + }); +});