From b164b8826f052b724428f90fb1235d53df992b4b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 14 Sep 2026 17:26:03 +0700 Subject: [PATCH] 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) --- src/env.ts | 28 +++- src/index.ts | 53 +++--- .../http/controllers/auth-controller.ts | 25 ++- src/interfaces/http/routes/index.ts | 44 ++++- src/{routes => interfaces/http}/swagger.ts | 157 +++++++++++++++++- src/shared/metrics/index.ts | 9 +- test/env.test.ts | 23 +++ test/s3-routing.test.ts | 132 +++++++++++++++ test/swagger.test.ts | 19 ++- 9 files changed, 443 insertions(+), 47 deletions(-) rename src/{routes => interfaces/http}/swagger.ts (60%) create mode 100644 test/s3-routing.test.ts diff --git a/src/env.ts b/src/env.ts index fb35aa1..a4f380e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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), diff --git a/src/index.ts b/src/index.ts index 8719ece..2d3ea82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,28 +49,39 @@ const gracefulShutdown = async (signal: string): Promise => { process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); -// Periodic maintenance intervals -setInterval(cleanupRateLimitCache, 60000); -setInterval( - () => { - const removed = fileInfoCache.cleanup(); - if (removed > 0) { - logger.info(`Cleaned up ${removed} expired cache entries`); - } - }, - 5 * 60 * 1000, +// 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(); + if (removed > 0) { + logger.info(`Cleaned up ${removed} expired cache entries`); + } + }, + 5 * 60 * 1000, + ), ); -setInterval( - () => { - const snapshot = metricsCollector.getSnapshot(); - logger.info('Metrics snapshot', { - uploadLatency: snapshot.uploadLatency, - uploadThroughput: snapshot.uploadThroughput.toFixed(2), - errorRate: snapshot.errorRate.toFixed(2), - cacheHitRate: snapshot.cacheHitRate.toFixed(2), - }); - }, - 5 * 60 * 1000, +unref( + setInterval( + () => { + const snapshot = metricsCollector.getSnapshot(); + logger.info('Metrics snapshot', { + uploadLatency: snapshot.uploadLatency, + uploadThroughput: snapshot.uploadThroughput.toFixed(2), + errorRate: snapshot.errorRate.toFixed(2), + cacheHitRate: snapshot.cacheHitRate.toFixed(2), + }); + }, + 5 * 60 * 1000, + ), ); logger.info('Application running successfully'); diff --git a/src/interfaces/http/controllers/auth-controller.ts b/src/interfaces/http/controllers/auth-controller.ts index 2ae4a90..f92cf02 100644 --- a/src/interfaces/http/controllers/auth-controller.ts +++ b/src/interfaces/http/controllers/auth-controller.ts @@ -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 => { export const handleMe = async (req: Request): Promise => { 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 => { }, }); - 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); diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts index b536be1..8acf6c3 100644 --- a/src/interfaces/http/routes/index.ts +++ b/src/interfaces/http/routes/index.ts @@ -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 => { 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 => { + 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 => { - if (shouldHandleS3(req)) return handleS3Direct(req); + if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req); return handleHome(); }, PUT: (req: Request): Promise => { @@ -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), diff --git a/src/routes/swagger.ts b/src/interfaces/http/swagger.ts similarity index 60% rename from src/routes/swagger.ts rename to src/interfaces/http/swagger.ts index 0029121..dda39ed 100644 --- a/src/routes/swagger.ts +++ b/src/interfaces/http/swagger.ts @@ -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 => { 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 => { }, }, }, + '/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.', + }, + }, + }, + }, }, }; diff --git a/src/shared/metrics/index.ts b/src/shared/metrics/index.ts index fc49103..02da246 100644 --- a/src/shared/metrics/index.ts +++ b/src/shared/metrics/index.ts @@ -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(), }; } diff --git a/test/env.test.ts b/test/env.test.ts index d0b20fb..94358f6 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -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); }); diff --git a/test/s3-routing.test.ts b/test/s3-routing.test.ts new file mode 100644 index 0000000..6e61376 --- /dev/null +++ b/test/s3-routing.test.ts @@ -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) => + (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); + }); +}); diff --git a/test/swagger.test.ts b/test/swagger.test.ts index 1626b4f..e0489ec 100644 --- a/test/swagger.test.ts +++ b/test/swagger.test.ts @@ -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; + }; + + 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();