refactor(fase1c): routing S3 fixes + auth/swagger/index/env
- routes: GET / teruskan headers ke shouldHandleS3; OPTIONS jawab
204 CORS generik bila bukan S3, handleS3Direct bila S3; komentar
bypass rate-limit S3 dipertahankan (registry abort pada 429)
- auth-controller: readLoginBody via LoginBodySchema; handleMe sederhanakan
(getAuthSession sudah cek bearer); import AuthSession dari dto
- swagger: pindah src/routes -> src/interfaces/http/swagger; tambah path
auth, /api/v1, /{bucket}, /{bucket}/{key}; version dari config.appVersion
- index: unref ketiga setInterval agar tak menahan process
- env: PORT fail-fast via PositiveIntSchema (default 4000 bila tak diset);
log config turun ke debug
- metrics: dokumentasikan uploadThroughput/queueSize/botUtilization
- test baru test/s3-routing.test.ts (GET / S3 vs home, OPTIONS 204 CORS)
This commit is contained in:
+26
-2
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import logger from './shared/logger/index';
|
||||
import { TELEGRAM_CHUNK_SIZE_MAX_BYTES } from './shared/utils/validation';
|
||||
import { PositiveIntSchema } from './shared/validation/schemas';
|
||||
|
||||
interface AppConfig {
|
||||
/** Application version (read from package.json, kept in sync by semantic-release prepare.mjs) */
|
||||
@@ -88,6 +89,27 @@ const parseNumber = (value: string | undefined, fallback: number): number => {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the PORT env var with fail-fast validation.
|
||||
*
|
||||
* Defaults to 4000 ONLY when the variable is absent or empty. A present but
|
||||
* invalid value ('abc', '-5', '0') throws so the service refuses to start
|
||||
* misconfigured instead of silently listening on the wrong port.
|
||||
*
|
||||
* @param value - The raw `PORT` env var value.
|
||||
* @returns The validated port number.
|
||||
* @throws {Error} When the value is present but not a positive integer.
|
||||
*/
|
||||
const parsePort = (value: string | undefined): number => {
|
||||
if (value === undefined || value === '') return 4000;
|
||||
const parsed = PositiveIntSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
logger.error(`Invalid PORT value: ${JSON.stringify(value)} — PORT must be a positive integer`);
|
||||
throw new Error('PORT must be a positive integer');
|
||||
}
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const parseTokens = (value: string | undefined): string[] =>
|
||||
(value || '')
|
||||
.split(',')
|
||||
@@ -172,7 +194,7 @@ export const config: AppConfig = {
|
||||
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
|
||||
baseUrl: process.env.BASE_URL!,
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
port: parseInt(process.env.PORT!, 10) || 4000,
|
||||
port: parsePort(process.env.PORT),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
rateLimitWindowMs: parseNumber(process.env.RATE_LIMIT_WINDOW_MS, 60000),
|
||||
@@ -197,7 +219,9 @@ export const config: AppConfig = {
|
||||
),
|
||||
};
|
||||
|
||||
logger.info('Environment variables loaded', {
|
||||
// Debug-level: every import of env.ts would otherwise dump the full config
|
||||
// (secrets masked, but still one noisy line per test file) to the log stream.
|
||||
logger.debug('Environment variables loaded', {
|
||||
config: {
|
||||
...config,
|
||||
botTokens: config.botTokens.map(maskSecret),
|
||||
|
||||
+13
-2
@@ -49,8 +49,16 @@ const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
// Periodic maintenance intervals
|
||||
setInterval(cleanupRateLimitCache, 60000);
|
||||
// Periodic maintenance intervals. Timers are unref'd so they never keep the
|
||||
// process alive on their own (safe no-op where `unref` is unavailable).
|
||||
const unref = (timer: unknown): void => {
|
||||
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
};
|
||||
|
||||
unref(setInterval(cleanupRateLimitCache, 60000));
|
||||
unref(
|
||||
setInterval(
|
||||
() => {
|
||||
const removed = fileInfoCache.cleanup();
|
||||
@@ -59,7 +67,9 @@ setInterval(
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
unref(
|
||||
setInterval(
|
||||
() => {
|
||||
const snapshot = metricsCollector.getSnapshot();
|
||||
@@ -71,6 +81,7 @@ setInterval(
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
|
||||
logger.info('Application running successfully');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { AuthSession } from '../../../application/dto/auth';
|
||||
import {
|
||||
type AuthSession,
|
||||
createLoginUseCase,
|
||||
createLogoutUseCase,
|
||||
createMeUseCase,
|
||||
} from '../../../application/use-cases/authenticate';
|
||||
import { config } from '../../../env';
|
||||
import { LoginBodySchema } from '../../../shared/validation/schemas';
|
||||
import {
|
||||
checkBearerToken,
|
||||
clearSessionCookie,
|
||||
createSessionCookie,
|
||||
getAuthSession,
|
||||
@@ -36,14 +36,17 @@ const notFound = (): Response => json({ error: 'Not found' }, 404);
|
||||
/**
|
||||
* Parses the login request body, extracting the `token` field.
|
||||
*
|
||||
* Validated through {@link LoginBodySchema} (the canonical boundary schema
|
||||
* for `POST /api/v1/auth/login`).
|
||||
*
|
||||
* @param req - The incoming HTTP request with a JSON body.
|
||||
* @returns The login token payload, or `null` when the body is invalid.
|
||||
*/
|
||||
const readLoginBody = async (req: Request): Promise<{ token: string } | null> => {
|
||||
try {
|
||||
const body = (await req.json()) as { token?: unknown };
|
||||
if (typeof body.token !== 'string' || body.token.length === 0) return null;
|
||||
return { token: body.token };
|
||||
const parsed = LoginBodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) return null;
|
||||
return { token: parsed.data.token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -119,8 +122,10 @@ export const handleLogout = async (): Promise<Response> => {
|
||||
export const handleMe = async (req: Request): Promise<Response> => {
|
||||
if (!isAuthEnabled()) return notFound();
|
||||
|
||||
// getAuthSession already checks the bearer token (cookie first, then
|
||||
// Authorization header) — no second check needed here.
|
||||
const session: AuthSession | null = getAuthSession(req);
|
||||
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
|
||||
if (!session) {
|
||||
return json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
@@ -132,13 +137,7 @@ export const handleMe = async (req: Request): Promise<Response> => {
|
||||
},
|
||||
});
|
||||
|
||||
const activeSession = session ?? {
|
||||
username: 'admin',
|
||||
expiresAt: null,
|
||||
method: 'bearer' as const,
|
||||
};
|
||||
|
||||
const result = await meUseCase(activeSession);
|
||||
const result = await meUseCase(session);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
|
||||
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
|
||||
@@ -9,6 +8,7 @@ import { handleUpload } from '../controllers/upload-controller';
|
||||
import { handleWebApiV1 } from '../controllers/web-api-controller';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { withRateLimit } from '../middleware/rate-limit';
|
||||
import { handleSwaggerHtml, handleSwaggerJson } from '../swagger';
|
||||
|
||||
/**
|
||||
* Dispatches an S3 request directly, bypassing rate limiting.
|
||||
@@ -16,7 +16,7 @@ import { withRateLimit } from '../middleware/rate-limit';
|
||||
* S3 API calls (used by Docker registry for blob pushes) must not be
|
||||
* rate-limited — large concurrent layer uploads would hit the limit and
|
||||
* fail. The Docker registry client retries on 5xx, not 4xx, so a 429
|
||||
* would abort the entire push.
|
||||
* would abort the entire push. Do NOT wrap this in withRateLimit.
|
||||
*
|
||||
* @param req - The incoming S3 request.
|
||||
* @returns The S3 response.
|
||||
@@ -25,6 +25,40 @@ const handleS3Direct = (req: Request): Promise<Response> => {
|
||||
return handleS3Request(req, getS3RouteBucket(req));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic CORS preflight for non-S3 API requests.
|
||||
*
|
||||
* S3 preflights are answered by the S3 controller (S3 XML CORS headers);
|
||||
* anything else (dashboard fetches, future API endpoints) gets a plain
|
||||
* permissive 204 so browsers can proceed.
|
||||
*
|
||||
* @returns A 204 No Content Response with permissive CORS headers.
|
||||
*/
|
||||
const apiOptionsResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, PUT, HEAD, DELETE, POST, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Authorization, Content-Type, X-Amz-Date, X-Amz-Content-Sha256',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles an OPTIONS request on the catch-all route.
|
||||
*
|
||||
* S3 clients preflight with SigV4 headers — those go to the S3 handler.
|
||||
* Anything else is a generic API preflight and gets a plain 204.
|
||||
*
|
||||
* @param req - The incoming OPTIONS request.
|
||||
* @returns The S3 or generic CORS preflight response.
|
||||
*/
|
||||
const handleCatchAllOptions = (req: Request): Promise<Response> => {
|
||||
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
|
||||
return Promise.resolve(apiOptionsResponse());
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines all HTTP routes for the application.
|
||||
*
|
||||
@@ -58,7 +92,7 @@ export const routes = {
|
||||
},
|
||||
'/': {
|
||||
GET: (req: Request): Promise<Response> => {
|
||||
if (shouldHandleS3(req)) return handleS3Direct(req);
|
||||
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
|
||||
return handleHome();
|
||||
},
|
||||
PUT: (req: Request): Promise<Response> => {
|
||||
@@ -69,7 +103,7 @@ export const routes = {
|
||||
HEAD: handleS3Direct,
|
||||
DELETE: handleS3Direct,
|
||||
POST: handleS3Direct,
|
||||
OPTIONS: handleS3Direct,
|
||||
OPTIONS: handleCatchAllOptions,
|
||||
},
|
||||
// Catch-all for S3 path-style requests (/{bucket}/{key} ...)
|
||||
// Only intercepts requests with S3 auth headers; others get 404.
|
||||
@@ -98,7 +132,7 @@ export const routes = {
|
||||
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
|
||||
return Promise.resolve(new Response('Not Found', { status: 404 }));
|
||||
},
|
||||
OPTIONS: handleS3Direct,
|
||||
OPTIONS: handleCatchAllOptions,
|
||||
},
|
||||
'/api/v1/auth/login': {
|
||||
POST: withRateLimit(handleLogin),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config } from '../env';
|
||||
import { config } from '../../env';
|
||||
|
||||
const errorSchema = (example: string) => ({
|
||||
type: 'object',
|
||||
@@ -50,8 +50,9 @@ export const handleSwaggerJson = async (): Promise<Response> => {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'FileDrop API',
|
||||
version: '1.0.0',
|
||||
description: 'File upload API with stream-based downloads.',
|
||||
version: config.appVersion,
|
||||
description:
|
||||
'File upload API with stream-based downloads, S3-compatible object storage, and admin auth.',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@@ -204,6 +205,156 @@ export const handleSwaggerJson = async (): Promise<Response> => {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/auth/login': {
|
||||
post: {
|
||||
summary: 'Admin Login',
|
||||
description:
|
||||
'Validates the admin API token and sets a signed session cookie. Returns 404 when auth is disabled.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: jsonContent(
|
||||
objectSchema({
|
||||
token: { type: 'string', example: 'admin-secret-token' },
|
||||
}),
|
||||
),
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Login successful; session cookie set.',
|
||||
content: jsonContent(
|
||||
objectSchema({
|
||||
username: { type: 'string', example: 'admin' },
|
||||
}),
|
||||
),
|
||||
},
|
||||
'400': {
|
||||
description: 'Token is required.',
|
||||
content: jsonContent(errorSchema('Token is required')),
|
||||
},
|
||||
'401': {
|
||||
description: 'Invalid token.',
|
||||
content: jsonContent(errorSchema('Invalid token')),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/auth/logout': {
|
||||
post: {
|
||||
summary: 'Admin Logout',
|
||||
description: 'Clears the session cookie.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Logout successful.',
|
||||
content: jsonContent(
|
||||
objectSchema({
|
||||
success: { type: 'boolean', example: true },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/auth/me': {
|
||||
get: {
|
||||
summary: 'Current User',
|
||||
description:
|
||||
'Returns the authenticated user from the session cookie or bearer token. Returns 404 when auth is disabled.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'User info.',
|
||||
content: jsonContent(
|
||||
objectSchema({
|
||||
username: { type: 'string', example: 'admin' },
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-05-18T10:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
'401': {
|
||||
description: 'Unauthorized.',
|
||||
content: jsonContent(errorSchema('Unauthorized')),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/{path}': {
|
||||
get: {
|
||||
summary: 'Web API (read)',
|
||||
description:
|
||||
'Public read endpoints: list buckets/objects and download files. See the dashboard for the full reference.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Requested resource.',
|
||||
},
|
||||
'404': {
|
||||
description: 'Not found.',
|
||||
content: jsonContent(errorSchema('Not found')),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/{bucket}': {
|
||||
get: {
|
||||
summary: 'S3 Bucket Operations',
|
||||
description:
|
||||
'S3-compatible bucket endpoint (SigV4 auth). Supports ListObjects, versioning queries, and bucket management. Served without rate limiting so Docker registry pushes are not aborted by 429s.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'bucket',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Bucket name.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'S3 XML response.',
|
||||
},
|
||||
'403': {
|
||||
description: 'Signature mismatch.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/{bucket}/{key}': {
|
||||
get: {
|
||||
summary: 'S3 Object Operations',
|
||||
description:
|
||||
'S3-compatible object endpoint (SigV4 auth): GetObject, PutObject, DeleteObject, and multipart uploads. Served without rate limiting so Docker registry pushes are not aborted by 429s.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'bucket',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Bucket name.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'key',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Object key.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'S3 XML or object bytes.',
|
||||
},
|
||||
'403': {
|
||||
description: 'Signature mismatch.',
|
||||
},
|
||||
'404': {
|
||||
description: 'NoSuchBucket / NoSuchKey.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,11 +82,16 @@ class MetricsCollector {
|
||||
p95: this.calculatePercentile(this.uploadTimes, 95),
|
||||
p99: this.calculatePercentile(this.uploadTimes, 99),
|
||||
},
|
||||
// Cumulative mean rate since process start (total requests / elapsed
|
||||
// minutes). Intended for the coarse 5-minute ops log in index.ts, not
|
||||
// a sliding-window throughput gauge.
|
||||
uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
|
||||
queueSize: 0, // Will be updated by queue
|
||||
// No upload queue or bot-utilization tracker exists yet — both stay 0
|
||||
// until one is wired in. Kept in the snapshot shape for compatibility.
|
||||
queueSize: 0,
|
||||
errorRate,
|
||||
cacheHitRate,
|
||||
botUtilization: 0, // Will be updated by bot tracker
|
||||
botUtilization: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,29 @@ describe('Environment Variables Validation', () => {
|
||||
expect(typeof config.port).toBe('number');
|
||||
});
|
||||
|
||||
it('startup fails fast when PORT is present but invalid', async () => {
|
||||
for (const badPort of ['abc', '-5', '0']) {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ['bun', '-e', "import('./src/env')"],
|
||||
cwd: `${import.meta.dir}/..`,
|
||||
env: {
|
||||
...process.env,
|
||||
BOT_TOKENS: '123456:ABC-DEF',
|
||||
STORAGE_CHANNEL_ID: '-1001234567890',
|
||||
BASE_URL: 'https://example.com',
|
||||
DATABASE_URL: 'postgresql://asephs:***@100.121.180.82:6432/test',
|
||||
PORT: badPort,
|
||||
},
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain('PORT must be a positive integer');
|
||||
}
|
||||
});
|
||||
|
||||
it("nodeEnv should be 'test' or 'development'", () => {
|
||||
expect(['test', 'development']).toContain(config.nodeEnv);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
||||
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
||||
process.env.BASE_URL = 'http://localhost:4000';
|
||||
process.env.DATABASE_URL = 'postgresql://asephs:***@100.121.180.82:6432/test';
|
||||
process.env.PORT = '4000';
|
||||
process.env.S3_ACCESS_KEY = 'filedrop-admin';
|
||||
process.env.S3_SECRET_KEY = 'unit-test-secret';
|
||||
|
||||
const bucket = {
|
||||
id: 'bucket-uuid',
|
||||
name: 'gitea',
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
mock.module('../src/infrastructure/persistence/repositories/bucket-repository', () => ({
|
||||
DrizzleBucketRepository: class {
|
||||
create = () => Promise.resolve(bucket);
|
||||
findByName = (name: string) => Promise.resolve(name === bucket.name ? bucket : null);
|
||||
list = () => Promise.resolve([bucket]);
|
||||
delete = () => Promise.resolve(true);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/infrastructure/persistence/repositories/file-repository', () => ({
|
||||
DrizzleFileRepository: class {
|
||||
countByBucket = () => Promise.resolve(0);
|
||||
findByBucketAndKey = () => Promise.resolve(null);
|
||||
listByPrefix = () => Promise.resolve({ objects: [], prefixes: [] });
|
||||
softDelete = () => Promise.resolve(true);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/infrastructure/persistence/repositories/multipart-repository', () => ({
|
||||
DrizzleMultipartRepository: class {
|
||||
abort = () => Promise.resolve();
|
||||
complete = () => Promise.resolve();
|
||||
create = () => Promise.resolve('upload-id');
|
||||
findById = () => Promise.resolve(null);
|
||||
insertPart = () => Promise.resolve();
|
||||
listParts = () => Promise.resolve([]);
|
||||
listByBucket = () => Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null });
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/infrastructure/telegram/chunked-storage', () => ({
|
||||
ChunkedStorage: class {
|
||||
createChunkedObjectResponse = () => Promise.resolve(new Response(''));
|
||||
storeFileInTelegramChunks = () => Promise.resolve({ fileHash: 'hash' });
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/interfaces/s3/auth', () => ({
|
||||
verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
|
||||
verifySignature: () => Promise.resolve({ isValid: true }),
|
||||
verifyBodyHash: () => null,
|
||||
isS3Request: (headers: Record<string, string>) =>
|
||||
(headers.authorization || '').startsWith('AWS4-HMAC-SHA256'),
|
||||
}));
|
||||
|
||||
mock.module('../src/infrastructure/telegram/bot-pool', () => ({
|
||||
botPool: {
|
||||
forwardToStorage: () =>
|
||||
Promise.resolve({
|
||||
telegramFileId: 'mock-tg-id',
|
||||
telegramFileUniqueId: 'mock-tg-unique',
|
||||
storageMessageId: 12345,
|
||||
}),
|
||||
getFileInfo: () =>
|
||||
Promise.resolve({
|
||||
bot_token: '123456:ABC-DEF',
|
||||
file_path: 'documents/file.txt',
|
||||
file_size: 100,
|
||||
mime_type: 'text/plain',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const AWS_AUTH =
|
||||
'AWS4-HMAC-SHA256 Credential=filedrop-admin/20260101/us-east-1/s3/aws4_request, ' +
|
||||
'SignedHeaders=host;x-amz-date, Signature=abc123';
|
||||
|
||||
describe('S3 routing (routes table)', () => {
|
||||
let routes: typeof import('../src/interfaces/http/routes/index').routes;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ routes } = await import('../src/interfaces/http/routes/index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('routes GET / with AWS4 auth headers to S3 (not the home page)', async () => {
|
||||
const res = await routes['/'].GET(
|
||||
new Request('http://localhost:4000/', {
|
||||
headers: { authorization: AWS_AUTH },
|
||||
}),
|
||||
);
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
// S3 answers with XML; the home page would be text/html.
|
||||
expect(contentType).toContain('application/xml');
|
||||
});
|
||||
|
||||
it('serves GET / without S3 headers as the home page (HTML 200)', async () => {
|
||||
const res = await routes['/'].GET(new Request('http://localhost:4000/'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/html');
|
||||
expect(await res.text()).toContain('FileDrop');
|
||||
});
|
||||
|
||||
it('answers OPTIONS /* without S3 headers as a generic 204 CORS preflight (not S3 XML)', async () => {
|
||||
const res = await routes['/*'].OPTIONS(
|
||||
new Request('http://localhost:4000/some/path', { method: 'OPTIONS' }),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
||||
});
|
||||
|
||||
it('routes OPTIONS /* with AWS4 auth headers to the S3 handler', async () => {
|
||||
const res = await routes['/*'].OPTIONS(
|
||||
new Request('http://localhost:4000/gitea/key', {
|
||||
method: 'OPTIONS',
|
||||
headers: { authorization: AWS_AUTH },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
});
|
||||
+18
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { handleSwaggerHtml, handleSwaggerJson } from '../src/routes/swagger';
|
||||
import { handleSwaggerHtml, handleSwaggerJson } from '../src/interfaces/http/swagger';
|
||||
|
||||
describe('Swagger Documentation Endpoints', () => {
|
||||
it('returns OpenAPI specification JSON', async () => {
|
||||
@@ -36,6 +36,23 @@ describe('Swagger Documentation Endpoints', () => {
|
||||
expect(downloadResponses['302'].description).toContain('Redirect');
|
||||
});
|
||||
|
||||
it('documents auth, web API, and S3 endpoints with the app version', async () => {
|
||||
const res = await handleSwaggerJson();
|
||||
const body = (await res.json()) as {
|
||||
info: { version: string };
|
||||
paths: Record<string, object>;
|
||||
};
|
||||
|
||||
expect(body.paths).toHaveProperty('/api/v1/auth/login');
|
||||
expect(body.paths).toHaveProperty('/api/v1/auth/logout');
|
||||
expect(body.paths).toHaveProperty('/api/v1/auth/me');
|
||||
expect(body.paths).toHaveProperty('/api/v1/{path}');
|
||||
expect(body.paths).toHaveProperty('/{bucket}');
|
||||
expect(body.paths).toHaveProperty('/{bucket}/{key}');
|
||||
expect(typeof body.info.version).toBe('string');
|
||||
expect(body.info.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns Swagger UI HTML page', async () => {
|
||||
const res = await handleSwaggerHtml();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user