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:
@@ -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