mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
chore: linting
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
SYNC_TOKEN=secret-sync-token
|
||||
|
||||
PORT=3939
|
||||
S3_ENDPOINT=http://localhost:8987
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_BUCKET=donut-sync
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
@@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "8987:9000"
|
||||
- "8988:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "donut-sync",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Test, type TestingModule } from "@nestjs/testing";
|
||||
import { AppController } from "./app.controller.js";
|
||||
import { AppService } from "./app.service.js";
|
||||
import { SyncService } from "./sync/sync.service.js";
|
||||
|
||||
describe("AppController", () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: {
|
||||
checkS3Connectivity: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe("root", () => {
|
||||
it("should return service name", () => {
|
||||
expect(appController.getHello()).toBe("Donut Sync Service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("health", () => {
|
||||
it("should return ok status", () => {
|
||||
expect(appController.getHealth()).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { AppService } from "./app.service.js";
|
||||
import { SyncService } from "./sync/sync.service.js";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(
|
||||
private readonly appService: AppService,
|
||||
private readonly syncService: SyncService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
|
||||
@Get("health")
|
||||
getHealth(): { status: string } {
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
@Get("readyz")
|
||||
async getReadiness(): Promise<{ status: string; s3: boolean }> {
|
||||
const s3Ready = await this.syncService.checkS3Connectivity();
|
||||
if (!s3Ready) {
|
||||
throw new HttpException(
|
||||
{ status: "not ready", s3: false },
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
return { status: "ready", s3: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { AppController } from "./app.controller.js";
|
||||
import { AppService } from "./app.service.js";
|
||||
import { SyncModule } from "./sync/sync.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
SyncModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return "Donut Sync Service";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException(
|
||||
"Missing or invalid authorization header",
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||
|
||||
if (!expectedToken) {
|
||||
throw new UnauthorizedException("Sync token not configured on server");
|
||||
}
|
||||
|
||||
if (token !== expectedToken) {
|
||||
throw new UnauthorizedException("Invalid sync token");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module.js";
|
||||
|
||||
function validateEnv() {
|
||||
const required = ["SYNC_TOKEN"];
|
||||
const missing = required.filter((key) => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Missing required environment variables: ${missing.join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
validateEnv();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
});
|
||||
|
||||
const port = process.env.PORT ?? 3929;
|
||||
await app.listen(port);
|
||||
console.log(`Donut Sync service running on port ${port}`);
|
||||
}
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,114 @@
|
||||
export class StatRequestDto {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class StatResponseDto {
|
||||
exists: boolean;
|
||||
lastModified?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export class PresignUploadRequestDto {
|
||||
key: string;
|
||||
contentType?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export class PresignUploadResponseDto {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export class PresignDownloadRequestDto {
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export class PresignDownloadResponseDto {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export class DeleteRequestDto {
|
||||
key: string;
|
||||
tombstoneKey?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export class DeleteResponseDto {
|
||||
deleted: boolean;
|
||||
tombstoneCreated: boolean;
|
||||
}
|
||||
|
||||
export class ListRequestDto {
|
||||
prefix: string;
|
||||
maxKeys?: number;
|
||||
continuationToken?: string;
|
||||
}
|
||||
|
||||
export class ListObjectDto {
|
||||
key: string;
|
||||
lastModified: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export class ListResponseDto {
|
||||
objects: ListObjectDto[];
|
||||
isTruncated: boolean;
|
||||
nextContinuationToken?: string;
|
||||
}
|
||||
|
||||
export class SubscribeEventDto {
|
||||
type: "change" | "delete" | "ping";
|
||||
key?: string;
|
||||
lastModified?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Batch presign DTOs
|
||||
export class PresignUploadBatchItemDto {
|
||||
key: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export class PresignUploadBatchRequestDto {
|
||||
items: PresignUploadBatchItemDto[];
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export class PresignUploadBatchItemResponseDto {
|
||||
key: string;
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export class PresignUploadBatchResponseDto {
|
||||
items: PresignUploadBatchItemResponseDto[];
|
||||
}
|
||||
|
||||
export class PresignDownloadBatchRequestDto {
|
||||
keys: string[];
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export class PresignDownloadBatchItemResponseDto {
|
||||
key: string;
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export class PresignDownloadBatchResponseDto {
|
||||
items: PresignDownloadBatchItemResponseDto[];
|
||||
}
|
||||
|
||||
// Delete prefix DTOs
|
||||
export class DeletePrefixRequestDto {
|
||||
prefix: string;
|
||||
tombstoneKey?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export class DeletePrefixResponseDto {
|
||||
deletedCount: number;
|
||||
tombstoneCreated: boolean;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
type MessageEvent,
|
||||
Post,
|
||||
Sse,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { map, type Observable } from "rxjs";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import type {
|
||||
DeletePrefixRequestDto,
|
||||
DeletePrefixResponseDto,
|
||||
DeleteRequestDto,
|
||||
DeleteResponseDto,
|
||||
ListRequestDto,
|
||||
ListResponseDto,
|
||||
PresignDownloadBatchRequestDto,
|
||||
PresignDownloadBatchResponseDto,
|
||||
PresignDownloadRequestDto,
|
||||
PresignDownloadResponseDto,
|
||||
PresignUploadBatchRequestDto,
|
||||
PresignUploadBatchResponseDto,
|
||||
PresignUploadRequestDto,
|
||||
PresignUploadResponseDto,
|
||||
StatRequestDto,
|
||||
StatResponseDto,
|
||||
} from "./dto/sync.dto.js";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
@Controller("v1/objects")
|
||||
@UseGuards(AuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
@Post("stat")
|
||||
async stat(@Body() dto: StatRequestDto): Promise<StatResponseDto> {
|
||||
return this.syncService.stat(dto);
|
||||
}
|
||||
|
||||
@Post("presign-upload")
|
||||
async presignUpload(
|
||||
@Body() dto: PresignUploadRequestDto,
|
||||
): Promise<PresignUploadResponseDto> {
|
||||
return this.syncService.presignUpload(dto);
|
||||
}
|
||||
|
||||
@Post("presign-download")
|
||||
async presignDownload(
|
||||
@Body() dto: PresignDownloadRequestDto,
|
||||
): Promise<PresignDownloadResponseDto> {
|
||||
return this.syncService.presignDownload(dto);
|
||||
}
|
||||
|
||||
@Post("delete")
|
||||
async delete(@Body() dto: DeleteRequestDto): Promise<DeleteResponseDto> {
|
||||
return this.syncService.delete(dto);
|
||||
}
|
||||
|
||||
@Post("list")
|
||||
async list(@Body() dto: ListRequestDto): Promise<ListResponseDto> {
|
||||
return this.syncService.list(dto);
|
||||
}
|
||||
|
||||
@Post("presign-upload-batch")
|
||||
async presignUploadBatch(
|
||||
@Body() dto: PresignUploadBatchRequestDto,
|
||||
): Promise<PresignUploadBatchResponseDto> {
|
||||
return this.syncService.presignUploadBatch(dto);
|
||||
}
|
||||
|
||||
@Post("presign-download-batch")
|
||||
async presignDownloadBatch(
|
||||
@Body() dto: PresignDownloadBatchRequestDto,
|
||||
): Promise<PresignDownloadBatchResponseDto> {
|
||||
return this.syncService.presignDownloadBatch(dto);
|
||||
}
|
||||
|
||||
@Post("delete-prefix")
|
||||
async deletePrefix(
|
||||
@Body() dto: DeletePrefixRequestDto,
|
||||
): Promise<DeletePrefixResponseDto> {
|
||||
return this.syncService.deletePrefix(dto);
|
||||
}
|
||||
|
||||
@Get("subscribe")
|
||||
@Sse()
|
||||
subscribe(): Observable<MessageEvent> {
|
||||
return this.syncService.subscribe(2000).pipe(
|
||||
map((event) => ({
|
||||
data: event,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { SyncController } from "./sync.controller.js";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, AuthGuard],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand as PutCmd,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { Injectable, type OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { interval, merge, type Observable, of, Subject } from "rxjs";
|
||||
import { catchError, filter, map, startWith, switchMap } from "rxjs/operators";
|
||||
import type {
|
||||
DeletePrefixRequestDto,
|
||||
DeletePrefixResponseDto,
|
||||
DeleteRequestDto,
|
||||
DeleteResponseDto,
|
||||
ListRequestDto,
|
||||
ListResponseDto,
|
||||
PresignDownloadBatchRequestDto,
|
||||
PresignDownloadBatchResponseDto,
|
||||
PresignDownloadRequestDto,
|
||||
PresignDownloadResponseDto,
|
||||
PresignUploadBatchRequestDto,
|
||||
PresignUploadBatchResponseDto,
|
||||
PresignUploadRequestDto,
|
||||
PresignUploadResponseDto,
|
||||
StatRequestDto,
|
||||
StatResponseDto,
|
||||
SubscribeEventDto,
|
||||
} from "./dto/sync.dto.js";
|
||||
|
||||
@Injectable()
|
||||
export class SyncService implements OnModuleInit {
|
||||
private s3Client: S3Client;
|
||||
private bucket: string;
|
||||
private lastKnownState: Map<string, string> = new Map();
|
||||
private changeSubject = new Subject<SubscribeEventDto>();
|
||||
private s3Ready = false;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const endpoint =
|
||||
this.configService.get<string>("S3_ENDPOINT") || "http://localhost:8987";
|
||||
const region = this.configService.get<string>("S3_REGION") || "us-east-1";
|
||||
const accessKeyId =
|
||||
this.configService.get<string>("S3_ACCESS_KEY_ID") || "minioadmin";
|
||||
const secretAccessKey =
|
||||
this.configService.get<string>("S3_SECRET_ACCESS_KEY") || "minioadmin";
|
||||
const forcePathStyle =
|
||||
this.configService.get<string>("S3_FORCE_PATH_STYLE") !== "false";
|
||||
|
||||
this.bucket = this.configService.get<string>("S3_BUCKET") || "donut-sync";
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureBucketExists();
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
try {
|
||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
this.s3Ready = true;
|
||||
} catch (error: unknown) {
|
||||
const isNotFound =
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"name" in error &&
|
||||
(error.name === "NotFound" ||
|
||||
error.name === "NoSuchBucket" ||
|
||||
error.name === "404");
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
await this.s3Client.send(
|
||||
new CreateBucketCommand({ Bucket: this.bucket }),
|
||||
);
|
||||
this.s3Ready = true;
|
||||
} catch (createError) {
|
||||
console.error("Failed to create S3 bucket:", createError);
|
||||
throw createError;
|
||||
}
|
||||
} else {
|
||||
console.error("S3 connection failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.s3Ready;
|
||||
}
|
||||
|
||||
async checkS3Connectivity(): Promise<boolean> {
|
||||
try {
|
||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stat(dto: StatRequestDto): Promise<StatResponseDto> {
|
||||
try {
|
||||
const response = await this.s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
lastModified: response.LastModified?.toISOString(),
|
||||
size: response.ContentLength,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"name" in error &&
|
||||
error.name === "NotFound"
|
||||
) {
|
||||
return { exists: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async presignUpload(
|
||||
dto: PresignUploadRequestDto,
|
||||
): Promise<PresignUploadResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const command = new PutCmd({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.key,
|
||||
ContentType: dto.contentType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
|
||||
return {
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async presignDownload(
|
||||
dto: PresignDownloadRequestDto,
|
||||
): Promise<PresignDownloadResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
|
||||
return {
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async delete(dto: DeleteRequestDto): Promise<DeleteResponseDto> {
|
||||
let deleted = false;
|
||||
let tombstoneCreated = false;
|
||||
|
||||
try {
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.key,
|
||||
}),
|
||||
);
|
||||
deleted = true;
|
||||
} catch {
|
||||
deleted = false;
|
||||
}
|
||||
|
||||
if (dto.tombstoneKey) {
|
||||
const tombstoneData = JSON.stringify({
|
||||
id: dto.key,
|
||||
deleted_at: dto.deletedAt || new Date().toISOString(),
|
||||
});
|
||||
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.tombstoneKey,
|
||||
Body: tombstoneData,
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
);
|
||||
tombstoneCreated = true;
|
||||
}
|
||||
|
||||
return { deleted, tombstoneCreated };
|
||||
}
|
||||
|
||||
async list(dto: ListRequestDto): Promise<ListResponseDto> {
|
||||
const response = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: dto.prefix,
|
||||
MaxKeys: dto.maxKeys || 1000,
|
||||
ContinuationToken: dto.continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const objects = (response.Contents || []).map((obj) => ({
|
||||
key: obj.Key || "",
|
||||
lastModified: obj.LastModified?.toISOString() || "",
|
||||
size: obj.Size || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
objects,
|
||||
isTruncated: response.IsTruncated || false,
|
||||
nextContinuationToken: response.NextContinuationToken,
|
||||
};
|
||||
}
|
||||
|
||||
async presignUploadBatch(
|
||||
dto: PresignUploadBatchRequestDto,
|
||||
): Promise<PresignUploadBatchResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
dto.items.map(async (item) => {
|
||||
const command = new PutCmd({
|
||||
Bucket: this.bucket,
|
||||
Key: item.key,
|
||||
ContentType: item.contentType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async presignDownloadBatch(
|
||||
dto: PresignDownloadBatchRequestDto,
|
||||
): Promise<PresignDownloadBatchResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
dto.keys.map(async (key) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
|
||||
return {
|
||||
key,
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async deletePrefix(
|
||||
dto: DeletePrefixRequestDto,
|
||||
): Promise<DeletePrefixResponseDto> {
|
||||
let deletedCount = 0;
|
||||
let tombstoneCreated = false;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
// Paginate through all objects with the prefix
|
||||
do {
|
||||
const listResponse = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: dto.prefix,
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const objects = listResponse.Contents || [];
|
||||
if (objects.length > 0) {
|
||||
// Delete objects in batches of 1000 (S3 limit)
|
||||
const deleteObjects = objects
|
||||
.filter((obj): obj is typeof obj & { Key: string } => !!obj.Key)
|
||||
.map((obj) => ({ Key: obj.Key }));
|
||||
|
||||
if (deleteObjects.length > 0) {
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: deleteObjects,
|
||||
Quiet: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
deletedCount += deleteObjects.length;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = listResponse.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
// Create tombstone if requested
|
||||
if (dto.tombstoneKey && deletedCount > 0) {
|
||||
const tombstoneData = JSON.stringify({
|
||||
prefix: dto.prefix,
|
||||
deleted_at: dto.deletedAt || new Date().toISOString(),
|
||||
deleted_count: deletedCount,
|
||||
});
|
||||
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dto.tombstoneKey,
|
||||
Body: tombstoneData,
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
);
|
||||
tombstoneCreated = true;
|
||||
}
|
||||
|
||||
return { deletedCount, tombstoneCreated };
|
||||
}
|
||||
|
||||
subscribe(pollIntervalMs = 2000): Observable<SubscribeEventDto> {
|
||||
const prefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
|
||||
|
||||
const pollChanges$ = interval(pollIntervalMs).pipe(
|
||||
startWith(0),
|
||||
switchMap(async () => {
|
||||
const events: SubscribeEventDto[] = [];
|
||||
const currentState = new Map<string, string>();
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
try {
|
||||
const result = await this.list({ prefix, maxKeys: 1000 });
|
||||
for (const obj of result.objects) {
|
||||
const stateKey = `${obj.key}:${obj.lastModified}`;
|
||||
currentState.set(obj.key, stateKey);
|
||||
|
||||
const previousStateKey = this.lastKnownState.get(obj.key);
|
||||
if (previousStateKey !== stateKey) {
|
||||
events.push({
|
||||
type: "change",
|
||||
key: obj.key,
|
||||
lastModified: obj.lastModified,
|
||||
size: obj.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to list prefix ${prefix}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key] of this.lastKnownState) {
|
||||
if (!currentState.has(key)) {
|
||||
events.push({
|
||||
type: "delete",
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastKnownState = currentState;
|
||||
return events;
|
||||
}),
|
||||
switchMap((events) => of(...events)),
|
||||
filter((event): event is SubscribeEventDto => event !== null),
|
||||
catchError((error) => {
|
||||
console.error("Error in subscribe poll:", error);
|
||||
return of({ type: "ping" as const });
|
||||
}),
|
||||
);
|
||||
|
||||
const ping$ = interval(30000).pipe(map(() => ({ type: "ping" as const })));
|
||||
|
||||
return merge(pollChanges$, ping$, this.changeSubject.asObservable());
|
||||
}
|
||||
|
||||
emitChange(event: SubscribeEventDto) {
|
||||
this.changeSubject.next(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { App } from "supertest/types";
|
||||
import { AppModule } from "./../src/app.module.js";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/")
|
||||
.expect(200)
|
||||
.expect("Donut Sync Service");
|
||||
});
|
||||
|
||||
it("/health (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/health")
|
||||
.expect(200)
|
||||
.expect({ status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { App } from "supertest/types";
|
||||
import { AppController } from "./../src/app.controller.js";
|
||||
import { AppService } from "./../src/app.service.js";
|
||||
import { SyncModule } from "./../src/sync/sync.module.js";
|
||||
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
objects: Array<{ key: string; lastModified: string; size: number }>;
|
||||
isTruncated: boolean;
|
||||
nextContinuationToken?: string;
|
||||
}
|
||||
|
||||
interface DeleteResponse {
|
||||
deleted: boolean;
|
||||
tombstoneCreated: boolean;
|
||||
}
|
||||
|
||||
interface StatResponse {
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
interface SSEError {
|
||||
code?: string;
|
||||
timeout?: boolean;
|
||||
response?: { status: number };
|
||||
}
|
||||
|
||||
const TEST_TOKEN = "test-sync-token";
|
||||
|
||||
describe("SyncController (e2e)", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.SYNC_TOKEN = TEST_TOKEN;
|
||||
process.env.S3_ENDPOINT =
|
||||
process.env.S3_ENDPOINT || "http://localhost:8987";
|
||||
process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "minioadmin";
|
||||
process.env.S3_SECRET_ACCESS_KEY =
|
||||
process.env.S3_SECRET_ACCESS_KEY || "minioadmin";
|
||||
process.env.S3_BUCKET = "donut-sync-test";
|
||||
process.env.S3_FORCE_PATH_STYLE = "true";
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
SyncModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("should reject requests without authorization header", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.send({ key: "test-key" })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should reject requests with invalid token", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", "Bearer invalid-token")
|
||||
.send({ key: "test-key" })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should accept requests with valid token", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: "nonexistent-key" })
|
||||
.expect(200)
|
||||
.expect({ exists: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/stat", () => {
|
||||
it("should return exists: false for non-existent key", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: "does-not-exist" })
|
||||
.expect(200)
|
||||
.expect({ exists: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/presign-upload", () => {
|
||||
it("should return a presigned upload URL", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: "test/upload-key.txt", contentType: "text/plain" })
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as PresignResponse;
|
||||
expect(body.url).toBeDefined();
|
||||
expect(body.url).toContain("test/upload-key.txt");
|
||||
expect(body.expiresAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/presign-download", () => {
|
||||
it("should return a presigned download URL", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-download")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: "test/download-key.txt" })
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as PresignResponse;
|
||||
expect(body.url).toBeDefined();
|
||||
expect(body.url).toContain("test/download-key.txt");
|
||||
expect(body.expiresAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/list", () => {
|
||||
it("should list objects with prefix", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/list")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ prefix: "profiles/" })
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as ListResponse;
|
||||
expect(body.objects).toBeDefined();
|
||||
expect(Array.isArray(body.objects)).toBe(true);
|
||||
expect(body.isTruncated).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/delete", () => {
|
||||
it("should delete object and create tombstone", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/delete")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({
|
||||
key: "test/to-delete.txt",
|
||||
tombstoneKey: "tombstones/test/to-delete.json",
|
||||
deletedAt: new Date().toISOString(),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as DeleteResponse;
|
||||
expect(body.deleted).toBeDefined();
|
||||
expect(body.tombstoneCreated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full upload/download cycle", () => {
|
||||
const testKey = `test/e2e-cycle-${Date.now()}.txt`;
|
||||
const testContent = "Hello from e2e test!";
|
||||
|
||||
it("should complete full upload/download cycle with presigned URLs", async () => {
|
||||
const uploadResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: testKey, contentType: "text/plain" })
|
||||
.expect(200);
|
||||
|
||||
const uploadBody = uploadResponse.body as PresignResponse;
|
||||
expect(uploadBody.url).toBeDefined();
|
||||
|
||||
const uploadResult = await fetch(uploadBody.url, {
|
||||
method: "PUT",
|
||||
body: testContent,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
expect(uploadResult.ok).toBe(true);
|
||||
|
||||
const statResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const statBody = statResponse.body as StatResponse;
|
||||
expect(statBody.exists).toBe(true);
|
||||
expect(statBody.size).toBeGreaterThan(0);
|
||||
|
||||
const downloadResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-download")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const downloadBody = downloadResponse.body as PresignResponse;
|
||||
const downloadResult = await fetch(downloadBody.url);
|
||||
expect(downloadResult.ok).toBe(true);
|
||||
|
||||
const downloadedContent = await downloadResult.text();
|
||||
expect(downloadedContent).toBe(testContent);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/v1/objects/delete")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const finalStatResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const finalStatBody = finalStatResponse.body as StatResponse;
|
||||
expect(finalStatBody.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/objects/subscribe (SSE)", () => {
|
||||
it("should reject SSE without authorization", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v1/objects/subscribe")
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should return SSE stream with valid token", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v1/objects/subscribe")
|
||||
.set("Authorization", `Bearer ${TEST_TOKEN}`)
|
||||
.set("Accept", "text/event-stream")
|
||||
.buffer(true)
|
||||
.timeout(3000)
|
||||
.catch((err: SSEError) => {
|
||||
if (err.code === "ECONNABORTED" || err.timeout) {
|
||||
return err.response ?? { status: 200 };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user