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:
+6
-3
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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,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;
|
||||||
|
|||||||
@@ -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, '');
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user