Slave/lifecycle #11

Merged
nobswebdev merged 9 commits from slave/lifecycle into master 2026-10-27 21:12:04 +00:00
4 changed files with 113 additions and 5 deletions
Showing only changes of commit 481eb0bcd6 - Show all commits
@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { throttleProfiles } from '../../../config/throttleProfiles';
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
import { DeleteUploadQueryDto } from '../dto/DeleteUploadQueryDto';
import { UploadService } from '../services/UploadService';
@Controller('uploads')
@@ -25,4 +26,10 @@ export class UploadController {
getZkCiphertext(@Param('id', ParseUUIDPipe) id: string) {
return this.uploadService.getZkUploadCiphertext(id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteUpload(@Param('id', ParseUUIDPipe) id: string, @Query() { delete_token }: DeleteUploadQueryDto) {
return this.uploadService.deleteUpload(id, delete_token);
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteUploadQueryDto {
@IsNotEmpty()
@IsString()
delete_token: string;
}
@@ -50,8 +50,14 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B
describe('UploadService', () => {
let service: UploadService;
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read'>>;
let uploadRepository: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; increment: jest.Mock };
let storageService: jest.Mocked<Pick<StorageService, 'write' | 'read' | 'unlink'>>;
let uploadRepository: {
create: jest.Mock;
save: jest.Mock;
findOne: jest.Mock;
increment: jest.Mock;
remove: jest.Mock;
};
let transaction: jest.Mock;
beforeEach(() => {
@@ -59,7 +65,8 @@ describe('UploadService', () => {
create: jest.fn((entityLike: unknown) => entityLike),
save: jest.fn(async (entity: unknown) => entity),
findOne: jest.fn(),
increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] })
increment: jest.fn().mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
remove: jest.fn().mockResolvedValue(undefined)
};
transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise<unknown>) => {
@@ -73,7 +80,8 @@ describe('UploadService', () => {
dataSource = { transaction };
storageService = {
write: jest.fn().mockResolvedValue(undefined),
read: jest.fn()
read: jest.fn(),
unlink: jest.fn().mockResolvedValue(undefined)
};
mockFileTypeFromBuffer.mockResolvedValue(undefined);
@@ -358,6 +366,68 @@ describe('UploadService', () => {
});
});
describe('deleteUpload', () => {
const deleteToken = fixedDeleteTokenBytes.toString('base64url');
it('unlinks ciphertext and removes upload when delete token matches', async () => {
const upload = {
storagePath: fixedId,
deleteTokenHash: hashDeleteToken(deleteToken)
};
uploadRepository.findOne.mockResolvedValue(upload);
await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined();
expect(uploadRepository.findOne).toHaveBeenCalledWith({
where: { id: fixedId, deleteTokenHash: hashDeleteToken(deleteToken) }
});
expect(storageService.unlink).toHaveBeenCalledWith(fixedId);
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
});
it('throws NotFoundException when upload is missing', async () => {
uploadRepository.findOne.mockResolvedValue(null);
await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow(NotFoundException);
expect(storageService.unlink).not.toHaveBeenCalled();
expect(uploadRepository.remove).not.toHaveBeenCalled();
});
it('throws NotFoundException when delete token is wrong', async () => {
uploadRepository.findOne.mockResolvedValue(null);
await expect(service.deleteUpload(fixedId, 'wrong-token')).rejects.toThrow(NotFoundException);
expect(storageService.unlink).not.toHaveBeenCalled();
expect(uploadRepository.remove).not.toHaveBeenCalled();
});
it('still removes upload when ciphertext file is already missing', async () => {
const upload = {
storagePath: fixedId,
deleteTokenHash: hashDeleteToken(deleteToken)
};
uploadRepository.findOne.mockResolvedValue(upload);
storageService.unlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
await expect(service.deleteUpload(fixedId, deleteToken)).resolves.toBeUndefined();
expect(uploadRepository.remove).toHaveBeenCalledWith(upload);
});
it('propagates non-ENOENT storage unlink errors', async () => {
uploadRepository.findOne.mockResolvedValue({
storagePath: fixedId,
deleteTokenHash: hashDeleteToken(deleteToken)
});
storageService.unlink.mockRejectedValue(new Error('permission denied'));
await expect(service.deleteUpload(fixedId, deleteToken)).rejects.toThrow('permission denied');
expect(uploadRepository.remove).not.toHaveBeenCalled();
});
});
describe('transaction rollback on storage failure', () => {
it('propagates storage write errors from the transaction', async () => {
storageService.write.mockRejectedValue(new Error('disk full'));
@@ -200,6 +200,30 @@ export class UploadService {
};
}
async deleteUpload(id: string, deleteToken: string): Promise<void> {
await this.dataSource.transaction(async manager => {
const uploadRepository = manager.getRepository(Upload);
const upload = await uploadRepository.findOne({
where: { id, deleteTokenHash: this.hashDeleteToken(deleteToken) }
});
if (!upload) {
throw new NotFoundException();
}
try {
await this.storageService.unlink(upload.storagePath);
} catch (error: unknown) {
if (!isErrorEnoent(error)) {
throw error;
}
}
await uploadRepository.remove(upload);
});
}
private hashDeleteToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}