chore: fix lint errors — noBannedTypes, import ordering, formatting
Deploy FileDrop / deploy (push) Successful in 42s
Deploy FileDrop / deploy (push) Successful in 42s
- Replace unsafe 'Function' type in test with ITelegramService interface - Biome auto-fix formatting and import sorting across 8 files Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,11 +205,14 @@ export class BotPool implements ITelegramService {
|
|||||||
|
|
||||||
if (attempt <= MAX_TRANSIENT_RETRIES && isTransientError(error)) {
|
if (attempt <= MAX_TRANSIENT_RETRIES && isTransientError(error)) {
|
||||||
const backoffMs = Math.min(1000 * 2 ** attempt, 10_000);
|
const backoffMs = Math.min(1000 * 2 ** attempt, 10_000);
|
||||||
logger.warn(`Transient error forwarding file, retrying (${attempt}/${MAX_TRANSIENT_RETRIES})`, {
|
logger.warn(
|
||||||
fileName,
|
`Transient error forwarding file, retrying (${attempt}/${MAX_TRANSIENT_RETRIES})`,
|
||||||
error: errorStr,
|
{
|
||||||
backoffMs,
|
fileName,
|
||||||
});
|
error: errorStr,
|
||||||
|
backoffMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
await sleep(backoffMs);
|
await sleep(backoffMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
|
||||||
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
||||||
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
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 { createChunkedObjectResponse } from '../../../utils/chunked-storage';
|
import { createChunkedObjectResponse } from '../../../utils/chunked-storage';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
|
||||||
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
|
|
||||||
import { locateZipEntry } from '../../../utils/zip';
|
import { locateZipEntry } from '../../../utils/zip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
listMultipartUploadsByBucket,
|
listMultipartUploadsByBucket,
|
||||||
} from '../../../db/multipart';
|
} from '../../../db/multipart';
|
||||||
import type { File } from '../../../db/schema';
|
import type { File } from '../../../db/schema';
|
||||||
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +44,6 @@ import {
|
|||||||
parseDeleteObjectsBody,
|
parseDeleteObjectsBody,
|
||||||
s3ErrorResponse,
|
s3ErrorResponse,
|
||||||
} from '../../../utils/s3/xml';
|
} from '../../../utils/s3/xml';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default S3 region returned when no region is explicitly configured.
|
* The default S3 region returned when no region is explicitly configured.
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
listObjectsByPrefix,
|
listObjectsByPrefix,
|
||||||
softDeleteFile,
|
softDeleteFile,
|
||||||
} from '../../../db/files-ext';
|
} from '../../../db/files-ext';
|
||||||
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
|
||||||
import {
|
import {
|
||||||
createChunkedObjectResponse,
|
createChunkedObjectResponse,
|
||||||
storeFileInTelegramChunks,
|
storeFileInTelegramChunks,
|
||||||
} from '../../../utils/chunked-storage';
|
} from '../../../utils/chunked-storage';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route parameters extracted from the URL path.
|
* Route parameters extracted from the URL path.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { db, files as fileSchema } from '../db';
|
|||||||
import { insertFileParts, listFileParts, type NewFilePartInput } from '../db/file-parts';
|
import { insertFileParts, listFileParts, type NewFilePartInput } from '../db/file-parts';
|
||||||
import type { File } from '../db/schema';
|
import type { File } from '../db/schema';
|
||||||
import { config } from '../env';
|
import { config } from '../env';
|
||||||
|
import { botPool } from '../infrastructure/telegram/bot-pool';
|
||||||
import { computeHash } from './file';
|
import { computeHash } from './file';
|
||||||
import { createGetObjectResponse, type ObjectPartSource } from './s3/object-stream';
|
import { createGetObjectResponse, type ObjectPartSource } from './s3/object-stream';
|
||||||
import type { RangeParseResult } from './s3/range';
|
import type { RangeParseResult } from './s3/range';
|
||||||
import { botPool } from '../infrastructure/telegram/bot-pool';
|
|
||||||
|
|
||||||
export type ChunkCompressionAlgorithm = 'gzip' | null;
|
export type ChunkCompressionAlgorithm = 'gzip' | null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { nanoid } from 'nanoid';
|
|||||||
import { db, files as fileSchema } from '../db';
|
import { db, files as fileSchema } from '../db';
|
||||||
import type { NewFile } from '../db/schema';
|
import type { NewFile } from '../db/schema';
|
||||||
import { config } from '../env';
|
import { config } from '../env';
|
||||||
import { cleanupTempFile } from './file';
|
|
||||||
import { botPool } from '../infrastructure/telegram/bot-pool';
|
import { botPool } from '../infrastructure/telegram/bot-pool';
|
||||||
|
import { cleanupTempFile } from './file';
|
||||||
import { createZip, type ZipEntry } from './zip';
|
import { createZip, type ZipEntry } from './zip';
|
||||||
|
|
||||||
export type PreparedUpload = {
|
export type PreparedUpload = {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ setEnv('SESSION_COOKIE_NAME', 'route_session');
|
|||||||
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
||||||
|
|
||||||
const { createSessionCookie } = await import('../src/utils/auth');
|
const { createSessionCookie } = await import('../src/utils/auth');
|
||||||
const { handleLogin, handleLogout, handleMe } = await import('../src/interfaces/http/controllers/auth-controller');
|
const { handleLogin, handleLogout, handleMe } = await import(
|
||||||
|
'../src/interfaces/http/controllers/auth-controller'
|
||||||
|
);
|
||||||
|
|
||||||
const jsonBody = async <T>(res: Response): Promise<T> => (await res.json()) as T;
|
const jsonBody = async <T>(res: Response): Promise<T> => (await res.json()) as T;
|
||||||
|
|
||||||
|
|||||||
+229
-229
@@ -230,254 +230,254 @@ describe('S3 API (production, SigV4)', () => {
|
|||||||
console.info('ℹ️ S3_SKIP: S3_SECRET_KEY not set — skipping S3 tests');
|
console.info('ℹ️ S3_SKIP: S3_SECRET_KEY not set — skipping S3 tests');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const bucketName = `e2e-s3-${TS}`;
|
||||||
|
|
||||||
const bucketName = `e2e-s3-${TS}`;
|
it('ListBuckets (GET /)', async () => {
|
||||||
|
const r = await s3Request('GET', '/');
|
||||||
it('ListBuckets (GET /)', async () => {
|
expect(r.status).toBe(200);
|
||||||
const r = await s3Request('GET', '/');
|
const xml = await r.text();
|
||||||
expect(r.status).toBe(200);
|
expect(xml).toContain('ListAllMyBucketsResult');
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('ListAllMyBucketsResult');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CreateBucket (PUT /{bucket})', async () => {
|
|
||||||
const r = await s3Request('PUT', `/${bucketName}`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
createdBuckets.push(bucketName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('HeadBucket (HEAD /{bucket})', async () => {
|
|
||||||
const r = await s3Request('HEAD', `/${bucketName}`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PutObject (PUT /{bucket}/{key})', async () => {
|
|
||||||
const r = await s3Request('PUT', `/${bucketName}/test-file.txt`, {
|
|
||||||
body: new TextEncoder().encode('hello s3'),
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.headers.get('etag')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PutObject — nested folder key', async () => {
|
|
||||||
const r = await s3Request('PUT', `/${bucketName}/folder/nested.txt`, {
|
|
||||||
body: new TextEncoder().encode('nested'),
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('HeadObject (HEAD /{bucket}/{key})', async () => {
|
|
||||||
const r = await s3Request('HEAD', `/${bucketName}/test-file.txt`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
expect(r.headers.get('etag')).toBeTruthy();
|
|
||||||
expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}/test-file.txt`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const text = await r.text();
|
|
||||||
expect(text).toContain('hello s3');
|
|
||||||
expect(r.headers.get('content-type')).toMatch(/text|octet/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ListObjectsV1 (GET /{bucket})', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}`);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('ListBucketResult');
|
|
||||||
expect(xml).toContain('test-file.txt');
|
|
||||||
expect(xml).toContain('folder/nested.txt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ListObjectsV1 — prefix filter', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}`, { query: { prefix: 'folder/' } });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('folder/nested.txt');
|
|
||||||
expect(xml).not.toContain('test-file.txt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ListObjectsV2 (GET /{bucket}?list-type=2)', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2' } });
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('ListBucketResultV2');
|
|
||||||
expect(xml).toContain('KeyCount');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ListObjectsV2 — continuation', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}`, {
|
|
||||||
query: { 'list-type': '2', 'max-keys': '1' },
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('IsTruncated');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DeleteObject (DELETE /{bucket}/{key})', async () => {
|
|
||||||
const r = await s3Request('DELETE', `/${bucketName}/folder/nested.txt`);
|
|
||||||
expect(r.status).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DeleteObjects (POST /{bucket}?delete) — batch', async () => {
|
|
||||||
const content = new TextEncoder().encode('del');
|
|
||||||
await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content });
|
|
||||||
await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content });
|
|
||||||
const deleteBody =
|
|
||||||
'<Delete><Object><Key>batch-1.txt</Key></Object><Object><Key>batch-2.txt</Key></Object></Delete>';
|
|
||||||
const r = await s3Request('POST', `/${bucketName}`, {
|
|
||||||
query: { delete: '' },
|
|
||||||
body: new TextEncoder().encode(deleteBody),
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('DeleteResult');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CopyObject (PUT /{dest} with x-amz-copy-source)', async () => {
|
|
||||||
const r = await s3Request('PUT', `/${bucketName}/copy-dest.txt`, {
|
|
||||||
headers: { 'x-amz-copy-source': `/${bucketName}/test-file.txt` },
|
|
||||||
});
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('CopyObjectResult');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Presigned URL — GET with X-Amz-Signature', async () => {
|
|
||||||
// Use s3Request to compute a presigned URL signature — verify the object
|
|
||||||
await s3Request('PUT', `/${bucketName}/presigned-test.txt`, {
|
|
||||||
body: new TextEncoder().encode('presigned content'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const host = new URL(BASE_URL).host;
|
it('CreateBucket (PUT /{bucket})', async () => {
|
||||||
const now = new Date();
|
const r = await s3Request('PUT', `/${bucketName}`);
|
||||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
expect(r.status).toBe(200);
|
||||||
const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`;
|
createdBuckets.push(bucketName);
|
||||||
const dateStamp = amzDate.slice(0, 8);
|
|
||||||
|
|
||||||
// Build canonical query string (must match server's buildCanonicalQueryString)
|
|
||||||
const sp = new URLSearchParams({
|
|
||||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
|
||||||
'X-Amz-Credential': `${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request`,
|
|
||||||
'X-Amz-Date': amzDate,
|
|
||||||
'X-Amz-Expires': '3600',
|
|
||||||
'X-Amz-SignedHeaders': 'host',
|
|
||||||
});
|
});
|
||||||
// Sort keys to match server's alphabetical sort
|
|
||||||
const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
||||||
const canonicalQs = sorted
|
|
||||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
it('HeadBucket (HEAD /{bucket})', async () => {
|
||||||
const hcr = sha256hex(canonical);
|
const r = await s3Request('HEAD', `/${bucketName}`);
|
||||||
const cs = `${dateStamp}/us-east-1/s3/aws4_request`;
|
expect(r.status).toBe(200);
|
||||||
const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`;
|
|
||||||
const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1');
|
|
||||||
const sig = hex(hmacSha256(sk, sts));
|
|
||||||
|
|
||||||
sp.set('X-Amz-Signature', sig);
|
|
||||||
const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
|
|
||||||
|
|
||||||
const r = await fetch(presignedUrl);
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
const text = await r.text();
|
|
||||||
expect(text).toContain('presigned content');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GetObject Range — returns partial single-part content', async () => {
|
|
||||||
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
|
||||||
headers: { range: 'bytes=0-4' },
|
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(206);
|
|
||||||
expect(r.headers.get('content-range')).toBe('bytes 0-4/8');
|
|
||||||
expect(await r.text()).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GetObject Range — invalid range returns 416 XML', async () => {
|
it('PutObject (PUT /{bucket}/{key})', async () => {
|
||||||
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
const r = await s3Request('PUT', `/${bucketName}/test-file.txt`, {
|
||||||
headers: { range: 'bytes=999-1000' },
|
body: new TextEncoder().encode('hello s3'),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.headers.get('etag')).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(416);
|
|
||||||
expect(r.headers.get('content-range')).toBe('bytes */8');
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('InvalidRange');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Multipart GetObject — returns complete concatenated body', async () => {
|
it('PutObject — nested folder key', async () => {
|
||||||
const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
|
const r = await s3Request('PUT', `/${bucketName}/folder/nested.txt`, {
|
||||||
query: { uploads: '' },
|
body: new TextEncoder().encode('nested'),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
});
|
});
|
||||||
expect(create.status).toBe(200);
|
|
||||||
const createXml = await create.text();
|
|
||||||
const uploadId = createXml.match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
|
|
||||||
expect(uploadId).toBeTruthy();
|
|
||||||
|
|
||||||
const part1 = new TextEncoder().encode('hello ');
|
it('HeadObject (HEAD /{bucket}/{key})', async () => {
|
||||||
const part2 = new TextEncoder().encode('multipart');
|
const r = await s3Request('HEAD', `/${bucketName}/test-file.txt`);
|
||||||
const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
|
expect(r.status).toBe(200);
|
||||||
query: { partNumber: '1', uploadId: uploadId! },
|
expect(r.headers.get('etag')).toBeTruthy();
|
||||||
body: part1,
|
expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
|
|
||||||
query: { partNumber: '2', uploadId: uploadId! },
|
it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => {
|
||||||
body: part2,
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const text = await r.text();
|
||||||
|
expect(text).toContain('hello s3');
|
||||||
|
expect(r.headers.get('content-type')).toMatch(/text|octet/);
|
||||||
});
|
});
|
||||||
expect(p1.status).toBe(200);
|
|
||||||
expect(p2.status).toBe(200);
|
|
||||||
|
|
||||||
const completeBody = `<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>${p1.headers.get('etag')}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>${p2.headers.get('etag')}</ETag></Part></CompleteMultipartUpload>`;
|
it('ListObjectsV1 (GET /{bucket})', async () => {
|
||||||
const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
|
const r = await s3Request('GET', `/${bucketName}`);
|
||||||
query: { uploadId: uploadId! },
|
expect(r.status).toBe(200);
|
||||||
body: new TextEncoder().encode(completeBody),
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('ListBucketResult');
|
||||||
|
expect(xml).toContain('test-file.txt');
|
||||||
|
expect(xml).toContain('folder/nested.txt');
|
||||||
});
|
});
|
||||||
expect(complete.status).toBe(200);
|
|
||||||
|
|
||||||
const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`);
|
it('ListObjectsV1 — prefix filter', async () => {
|
||||||
expect(full.status).toBe(200);
|
const r = await s3Request('GET', `/${bucketName}`, { query: { prefix: 'folder/' } });
|
||||||
expect(await full.text()).toBe('hello multipart');
|
expect(r.status).toBe(200);
|
||||||
|
const xml = await r.text();
|
||||||
const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, {
|
expect(xml).toContain('folder/nested.txt');
|
||||||
headers: { range: 'bytes=3-9' },
|
expect(xml).not.toContain('test-file.txt');
|
||||||
});
|
});
|
||||||
expect(partial.status).toBe(206);
|
|
||||||
expect(partial.headers.get('content-range')).toBe('bytes 3-9/15');
|
|
||||||
expect(await partial.text()).toBe('lo mult');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Delete bucket — must be empty first', async () => {
|
it('ListObjectsV2 (GET /{bucket}?list-type=2)', async () => {
|
||||||
// Clean up remaining objects
|
const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2' } });
|
||||||
await s3Request('DELETE', `/${bucketName}/test-file.txt`);
|
expect(r.status).toBe(200);
|
||||||
await s3Request('DELETE', `/${bucketName}/copy-dest.txt`);
|
const xml = await r.text();
|
||||||
await s3Request('DELETE', `/${bucketName}/presigned-test.txt`);
|
expect(xml).toContain('ListBucketResultV2');
|
||||||
await s3Request('DELETE', `/${bucketName}/multipart-full.txt`);
|
expect(xml).toContain('KeyCount');
|
||||||
|
});
|
||||||
const r = await s3Request('DELETE', `/${bucketName}`);
|
|
||||||
expect(r.status).toBe(204);
|
it('ListObjectsV2 — continuation', async () => {
|
||||||
createdBuckets = createdBuckets.filter((b) => b !== bucketName);
|
const r = await s3Request('GET', `/${bucketName}`, {
|
||||||
});
|
query: { 'list-type': '2', 'max-keys': '1' },
|
||||||
|
});
|
||||||
it('S3 error — NoSuchBucket returns 404 XML', async () => {
|
expect(r.status).toBe(200);
|
||||||
const r = await s3Request('GET', '/bucket-nonexistent-xyz');
|
const xml = await r.text();
|
||||||
expect(r.status).toBe(404);
|
expect(xml).toContain('IsTruncated');
|
||||||
const xml = await r.text();
|
});
|
||||||
expect(xml).toContain('NoSuchBucket');
|
|
||||||
});
|
it('DeleteObject (DELETE /{bucket}/{key})', async () => {
|
||||||
|
const r = await s3Request('DELETE', `/${bucketName}/folder/nested.txt`);
|
||||||
it('S3 error — bad signature returns 403', async () => {
|
expect(r.status).toBe(204);
|
||||||
const r = await fetch(`${BASE_URL}/`, {
|
});
|
||||||
headers: {
|
|
||||||
Authorization:
|
it('DeleteObjects (POST /{bucket}?delete) — batch', async () => {
|
||||||
'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00',
|
const content = new TextEncoder().encode('del');
|
||||||
'x-amz-date': '20260701T000000Z',
|
await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content });
|
||||||
'x-amz-content-sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content });
|
||||||
},
|
const deleteBody =
|
||||||
|
'<Delete><Object><Key>batch-1.txt</Key></Object><Object><Key>batch-2.txt</Key></Object></Delete>';
|
||||||
|
const r = await s3Request('POST', `/${bucketName}`, {
|
||||||
|
query: { delete: '' },
|
||||||
|
body: new TextEncoder().encode(deleteBody),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('DeleteResult');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CopyObject (PUT /{dest} with x-amz-copy-source)', async () => {
|
||||||
|
const r = await s3Request('PUT', `/${bucketName}/copy-dest.txt`, {
|
||||||
|
headers: { 'x-amz-copy-source': `/${bucketName}/test-file.txt` },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('CopyObjectResult');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Presigned URL — GET with X-Amz-Signature', async () => {
|
||||||
|
// Use s3Request to compute a presigned URL signature — verify the object
|
||||||
|
await s3Request('PUT', `/${bucketName}/presigned-test.txt`, {
|
||||||
|
body: new TextEncoder().encode('presigned content'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = new URL(BASE_URL).host;
|
||||||
|
const now = new Date();
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`;
|
||||||
|
const dateStamp = amzDate.slice(0, 8);
|
||||||
|
|
||||||
|
// Build canonical query string (must match server's buildCanonicalQueryString)
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||||
|
'X-Amz-Credential': `${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request`,
|
||||||
|
'X-Amz-Date': amzDate,
|
||||||
|
'X-Amz-Expires': '3600',
|
||||||
|
'X-Amz-SignedHeaders': 'host',
|
||||||
|
});
|
||||||
|
// Sort keys to match server's alphabetical sort
|
||||||
|
const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const canonicalQs = sorted
|
||||||
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`;
|
||||||
|
const hcr = sha256hex(canonical);
|
||||||
|
const cs = `${dateStamp}/us-east-1/s3/aws4_request`;
|
||||||
|
const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`;
|
||||||
|
const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1');
|
||||||
|
const sig = hex(hmacSha256(sk, sts));
|
||||||
|
|
||||||
|
sp.set('X-Amz-Signature', sig);
|
||||||
|
const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`;
|
||||||
|
|
||||||
|
const r = await fetch(presignedUrl);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const text = await r.text();
|
||||||
|
expect(text).toContain('presigned content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetObject Range — returns partial single-part content', async () => {
|
||||||
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
||||||
|
headers: { range: 'bytes=0-4' },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(206);
|
||||||
|
expect(r.headers.get('content-range')).toBe('bytes 0-4/8');
|
||||||
|
expect(await r.text()).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GetObject Range — invalid range returns 416 XML', async () => {
|
||||||
|
const r = await s3Request('GET', `/${bucketName}/test-file.txt`, {
|
||||||
|
headers: { range: 'bytes=999-1000' },
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(416);
|
||||||
|
expect(r.headers.get('content-range')).toBe('bytes */8');
|
||||||
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('InvalidRange');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Multipart GetObject — returns complete concatenated body', async () => {
|
||||||
|
const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
|
||||||
|
query: { uploads: '' },
|
||||||
|
});
|
||||||
|
expect(create.status).toBe(200);
|
||||||
|
const createXml = await create.text();
|
||||||
|
const uploadId = createXml.match(/<UploadId>([^<]+)<\/UploadId>/)?.[1];
|
||||||
|
expect(uploadId).toBeTruthy();
|
||||||
|
|
||||||
|
const part1 = new TextEncoder().encode('hello ');
|
||||||
|
const part2 = new TextEncoder().encode('multipart');
|
||||||
|
const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
|
||||||
|
query: { partNumber: '1', uploadId: uploadId! },
|
||||||
|
body: part1,
|
||||||
|
});
|
||||||
|
const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, {
|
||||||
|
query: { partNumber: '2', uploadId: uploadId! },
|
||||||
|
body: part2,
|
||||||
|
});
|
||||||
|
expect(p1.status).toBe(200);
|
||||||
|
expect(p2.status).toBe(200);
|
||||||
|
|
||||||
|
const completeBody = `<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>${p1.headers.get('etag')}</ETag></Part><Part><PartNumber>2</PartNumber><ETag>${p2.headers.get('etag')}</ETag></Part></CompleteMultipartUpload>`;
|
||||||
|
const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, {
|
||||||
|
query: { uploadId: uploadId! },
|
||||||
|
body: new TextEncoder().encode(completeBody),
|
||||||
|
});
|
||||||
|
expect(complete.status).toBe(200);
|
||||||
|
|
||||||
|
const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`);
|
||||||
|
expect(full.status).toBe(200);
|
||||||
|
expect(await full.text()).toBe('hello multipart');
|
||||||
|
|
||||||
|
const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, {
|
||||||
|
headers: { range: 'bytes=3-9' },
|
||||||
|
});
|
||||||
|
expect(partial.status).toBe(206);
|
||||||
|
expect(partial.headers.get('content-range')).toBe('bytes 3-9/15');
|
||||||
|
expect(await partial.text()).toBe('lo mult');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Delete bucket — must be empty first', async () => {
|
||||||
|
// Clean up remaining objects
|
||||||
|
await s3Request('DELETE', `/${bucketName}/test-file.txt`);
|
||||||
|
await s3Request('DELETE', `/${bucketName}/copy-dest.txt`);
|
||||||
|
await s3Request('DELETE', `/${bucketName}/presigned-test.txt`);
|
||||||
|
await s3Request('DELETE', `/${bucketName}/multipart-full.txt`);
|
||||||
|
|
||||||
|
const r = await s3Request('DELETE', `/${bucketName}`);
|
||||||
|
expect(r.status).toBe(204);
|
||||||
|
createdBuckets = createdBuckets.filter((b) => b !== bucketName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('S3 error — NoSuchBucket returns 404 XML', async () => {
|
||||||
|
const r = await s3Request('GET', '/bucket-nonexistent-xyz');
|
||||||
|
expect(r.status).toBe(404);
|
||||||
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('NoSuchBucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('S3 error — bad signature returns 403', async () => {
|
||||||
|
const r = await fetch(`${BASE_URL}/`, {
|
||||||
|
headers: {
|
||||||
|
Authorization:
|
||||||
|
'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00',
|
||||||
|
'x-amz-date': '20260701T000000Z',
|
||||||
|
'x-amz-content-sha256':
|
||||||
|
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
const xml = await r.text();
|
||||||
|
expect(xml).toContain('Error');
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(403);
|
|
||||||
const xml = await r.text();
|
|
||||||
expect(xml).toContain('Error');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
import type { ITelegramService } from '../src/domain/ports/telegram-service';
|
||||||
import { config } from '../src/env';
|
import { config } from '../src/env';
|
||||||
import logger from '../src/utils/logger';
|
import logger from '../src/utils/logger';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ const infoSpy = spyOn(logger, 'info');
|
|||||||
const errorSpy = spyOn(logger, 'error');
|
const errorSpy = spyOn(logger, 'error');
|
||||||
|
|
||||||
describe('Telegram API Utilities', () => {
|
describe('Telegram API Utilities', () => {
|
||||||
let botPool: { forwardToStorage: Function; getFileInfo: Function; enqueueUpload: Function };
|
let botPool: ITelegramService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
infoSpy.mockClear();
|
infoSpy.mockClear();
|
||||||
|
|||||||
Reference in New Issue
Block a user