diff --git a/src/rest/restStorage.ts b/src/rest/restStorage.ts new file mode 100644 index 0000000..7ea9e0f --- /dev/null +++ b/src/rest/restStorage.ts @@ -0,0 +1,90 @@ +import {S3Service} from "../services/s3Service"; +import multer from "multer" +import express from "express"; +import {asyncHandler} from "./middleware/asyncHandler"; +import {badRequest, created, forbidden, notFound, ok} from "./utils/responses"; +import {vi} from "vitest"; + +vi.mock("../../src/services/db/UserService", () => ({ + UserService: { + create: vi.fn() + } +})); + +export class RestStorage { + + constructor(private readonly s3Service: S3Service) { + } + + public createRouter() { + const router = express.Router(); + + const upload = multer({ + storage: multer.memoryStorage(), + limits: {fileSize: 10 * 1024 * 1024}, + }); + + router.post( + "/upload", + upload.single('image'), + asyncHandler(async (req, res) => { + if (!req.file) { + return badRequest(res, "No file provided."); + } + + const userId = req.payload.uuid; + const objectKey = await this.s3Service.uploadFile(req.file, userId); + + return created(res, {message: "File uploaded successfully", objectKey}) + }) + ); + + router.get("/files", asyncHandler(async (req, res) => { + const userId = req.payload.uuid; + const files = await this.s3Service.listFilesForUser(userId); + + return ok(res, files); + })); + + router.get(/\/files\/(.*)\/url$/, asyncHandler(async (req, res) => { + const userId = req.payload.uuid; + const objectKey =req.params[0]; + + console.log(userId); + console.log(objectKey) + + if (!objectKey || !objectKey.startsWith(`user-${userId}`)) { + return forbidden(res); + } + try { + const expiresInSeconds = 60; + const downloadUrl = await this.s3Service.getSignedDownloadUrl(objectKey, expiresInSeconds); + + return ok(res, {url: downloadUrl}); + } catch (error: any) { + if (error.name === "NoSuchKey") { + return notFound(res, "File not found."); + } else { + throw error; + } + } + + })); + + router.delete(/\/files\/(.*)/, asyncHandler(async (req, res) => { // <-- ÄNDERUNG HIER + const userId = req.payload.uuid; + const objectKey =req.params[0]; + + console.log(objectKey) + if (!objectKey.startsWith(`user-${userId}/`)) { + return forbidden(res); + } + + await this.s3Service.deleteFile(objectKey); + + return ok(res, "File deleted successfully"); + })); + + return router; + } +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 55df531..6fdd24c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import {disconnectFromDatabase} from "./services/db/database.service"; import {SpotifyTokenService} from "./services/spotifyTokenService"; import {WeatherPollingService} from "./services/weatherPollingService"; import {S3Service} from "./services/s3Service"; +import {RestStorage} from "./rest/restStorage"; interface ServerDependencies { userService: UserService; @@ -65,7 +66,7 @@ export class Server { watchUserChanges(); this._setupMiddleware(); - this._setupRoutes(userService, spotifyTokenService, jwtAuthenticator); + this._setupRoutes(userService, spotifyTokenService, jwtAuthenticator, s3Service); this._setupErrorHandling(); this.httpServer = this.app.listen(this.config.port, () => { @@ -100,13 +101,14 @@ export class Server { this.app.use(express.json({limit: "2mb"})); } - private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService, jwtAuthenticator: JwtAuthenticator): void { + private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService, jwtAuthenticator: JwtAuthenticator, s3Service: S3Service): void { const _authenticateJwt = authenticateJwt(jwtAuthenticator); const restAuth = new RestAuth(userService, jwtAuthenticator); const restUser = new RestUser(userService); const spotifyTokenGenerator = new SpotifyTokenGenerator(spotifyTokenService); const jwtTokenExtractor = new JwtTokenPropertiesExtractor(); + const storage = new RestStorage(s3Service); this.app.get("/api/healthz", (_req, res) => res.status(200).send({status: "ok"})); @@ -116,6 +118,7 @@ export class Server { this.app.use("/api/spotify", _authenticateJwt, spotifyLimiter, spotifyTokenGenerator.createRouter()); this.app.use("/api/user", _authenticateJwt, restUser.createRouter()); this.app.use("/api/jwt", _authenticateJwt, jwtTokenExtractor.createRouter()); + this.app.use("/api/storage", _authenticateJwt, storage.createRouter()); this.app.use("/api/websocket", _authenticateJwt, (req, res, next) => { if (this.webSocketServer) { diff --git a/src/services/s3Service.ts b/src/services/s3Service.ts index 237f6e6..346d44f 100644 --- a/src/services/s3Service.ts +++ b/src/services/s3Service.ts @@ -2,7 +2,7 @@ import { S3Client, CreateBucketCommand, PutObjectCommand, - GetObjectCommand + GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { randomUUID } from 'crypto'; @@ -74,6 +74,30 @@ export class S3Service { return objectKey; } + async listFilesForUser(userId: string): Promise<{ key: string, lastModified: Date }[]> { + const command = new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: `user-${userId}/`, + }); + + const response = await this.client.send(command); + + return response.Contents?.map(item => ({ + key: item.Key!, + lastModified: item.LastModified!, + })) || []; + } + + async deleteFile(objectKey: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: objectKey, + }); + + await this.client.send(command); + console.log(`File deleted: ${objectKey}`); + } + async getSignedDownloadUrl(objectKey: string, expiresIn: number = 60): Promise { const command = new GetObjectCommand({ Bucket: this.bucketName, diff --git a/tests/helpers/testSetup.ts b/tests/helpers/testSetup.ts index 39e4f31..b93d0a0 100644 --- a/tests/helpers/testSetup.ts +++ b/tests/helpers/testSetup.ts @@ -124,4 +124,12 @@ export const createMockSpotifyApiService = () => ({ export const createMockSpotifyPollingService = () => ({ startPollingForUser: vi.fn(), stopPollingForUser: vi.fn() +}); + +export const createMockS3Service = () => ({ + ensureBucketExists: vi.fn(), + uploadFile: vi.fn(), + listFilesForUser: vi.fn(), + deleteFile: vi.fn(), + getSignedDownloadUrl: vi.fn(), }); \ No newline at end of file diff --git a/tests/rest/restStorage.test.ts b/tests/rest/restStorage.test.ts new file mode 100644 index 0000000..c4c40e5 --- /dev/null +++ b/tests/rest/restStorage.test.ts @@ -0,0 +1,151 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from "vitest"; +import request from "supertest"; +import express from "express"; +import {RestStorage} from "../../src/rest/restStorage"; +import {S3Service} from "../../src/services/s3Service"; + +// @ts-ignore +import {createMockS3Service, createTestApp} from "../helpers/testSetup"; + +vi.mock("../../src/services/s3Service"); + +describe("RestStorage", () => { + let app: express.Application; + let mockS3Service: any; + + const requestingUserUUID = "user-id-123"; + + beforeEach(() => { + vi.clearAllMocks(); + + mockS3Service = createMockS3Service(); + + const restStorage = new RestStorage(mockS3Service as unknown as S3Service); + + app = createTestApp(restStorage.createRouter(), "/storage", { + uuid: requestingUserUUID, + name: "name", + id: "1234" + }); + + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("POST /upload", () => { + it("should upload a file and return a 201 Created response", async () => { + const objectKey = `user-${requestingUserUUID}/generated-uuid.jpg`; + mockS3Service.uploadFile.mockResolvedValue(objectKey); + + const response = await request(app) // Verwende die 'app' aus dem beforeEach + .post("/storage/upload") + .attach('image', Buffer.from("fake image data"), "test.jpg") + .expect(201); + + expect(response.body.data).toEqual({ + message: "File uploaded successfully", + objectKey: objectKey, + }); + + expect(mockS3Service.uploadFile).toHaveBeenCalledOnce(); + expect(mockS3Service.uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'test.jpg' + }), + requestingUserUUID + ); + }); + + it("should return a 400 Bad Request if no file is provided", async () => { + const response = await request(app) + .post("/storage/upload") + .expect(400); + + expect(response.body.data.message).toBe("No file provided."); + expect(mockS3Service.uploadFile).not.toHaveBeenCalled(); + }); + }); + + describe("GET /files", () => { + it("should return a list of files for the current user", async () => { + const mockFiles = [ + {key: `user-${requestingUserUUID}/file1.txt`, lastModified: new Date()}, + {key: `user-${requestingUserUUID}/image.png`, lastModified: new Date()}, + ]; + mockS3Service.listFilesForUser.mockResolvedValue(mockFiles); + + const response = await request(app) + .get("/storage/files") + .expect(200); + + const responseData = JSON.parse(JSON.stringify(mockFiles)); + expect(response.body.data).toEqual(responseData); + expect(mockS3Service.listFilesForUser).toHaveBeenCalledOnce(); + expect(mockS3Service.listFilesForUser).toHaveBeenCalledWith(requestingUserUUID); + }); + }); + + describe("GET /files/:key/url", () => { // Beschreibung an die Logik angepasst + it("should return a signed URL for a file owned by the user", async () => { + const objectKey = `user-${requestingUserUUID}/my-photo.jpg`; + const signedUrl = "http://s3.com/signed-url"; + mockS3Service.getSignedDownloadUrl.mockResolvedValue(signedUrl); + + const response = await request(app) + .get(`/storage/files/${encodeURIComponent(objectKey)}/url`) + .expect(200); + + expect(response.body.data).toEqual({url: signedUrl}); + expect(mockS3Service.getSignedDownloadUrl).toHaveBeenCalledOnce(); + expect(mockS3Service.getSignedDownloadUrl).toHaveBeenCalledWith(objectKey, 60); + }); + + it("should return 403 Forbidden if the user tries to access another user's file", async () => { + const objectKey = "user-another-user/secret.txt"; + + await request(app) + .get(`/storage/files/${encodeURIComponent(objectKey)}/url`) + .expect(403); + + expect(mockS3Service.getSignedDownloadUrl).not.toHaveBeenCalled(); + }); + + it("should return 404 Not Found if the file does not exist", async () => { + const objectKey = `user-${requestingUserUUID}/non-existent.jpg`; + mockS3Service.getSignedDownloadUrl.mockRejectedValue({name: "NoSuchKey"}); + + const response = await request(app) + .get(`/storage/files/${encodeURIComponent(objectKey)}/url`) + .expect(404); + + expect(response.body.data.message).toBe("File not found."); + }); + }); + + describe("DELETE /files/:key", () => { // Beschreibung an die Logik angepasst + it("should delete a file owned by the user", async () => { + const objectKey = `user-${requestingUserUUID}/file-to-delete.pdf`; + mockS3Service.deleteFile.mockResolvedValue(undefined); + + const response = await request(app) + .delete(`/storage/files/${encodeURIComponent(objectKey)}`) + .expect(200); + + expect(response.body.data).toBe("File deleted successfully"); + expect(mockS3Service.deleteFile).toHaveBeenCalledOnce(); + expect(mockS3Service.deleteFile).toHaveBeenCalledWith(objectKey); + }); + + it("should return 403 Forbidden if the user tries to delete another user's file", async () => { + const objectKey = "user-another-user/data.csv"; + + await request(app) + .delete(`/storage/files/${encodeURIComponent(objectKey)}`) + .expect(403); + + expect(mockS3Service.deleteFile).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/tests/services/s3Service.test.ts b/tests/services/s3Service.test.ts index 6a3915b..2761f88 100644 --- a/tests/services/s3Service.test.ts +++ b/tests/services/s3Service.test.ts @@ -11,8 +11,14 @@ vi.mock('@aws-sdk/client-s3', async (importOriginal) => { }); import { S3Service, S3ClientConfig } from '../../src/services/s3Service'; -import { S3Client, CreateBucketCommand, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { + S3Client, + CreateBucketCommand, + PutObjectCommand, + GetObjectCommand, + ListObjectsV2Command, + DeleteObjectCommand +} from '@aws-sdk/client-s3';import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const testConfig: S3ClientConfig = { endpoint: 'http://test-minio', @@ -73,7 +79,7 @@ describe('S3Service', () => { originalname: 'test-image.jpg', buffer: Buffer.from('test-data'), mimetype: 'image/jpeg', - }; + } as any; const userId = 'user-123'; @@ -92,6 +98,82 @@ describe('S3Service', () => { }); }); + describe("listFilesForUser", () => { + const userId = "user-123"; + + it("should return a correctly formatted list of files for a user", async () => { + const mockS3Response = { + Contents: [ + { Key: `user-${userId}/file1.txt`, LastModified: new Date('2023-01-01') }, + { Key: `user-${userId}/image.jpg`, LastModified: new Date('2023-01-02') }, + ], + }; + mockSend.mockResolvedValue(mockS3Response); + + const files = await s3Service.listFilesForUser(userId); + + expect(mockSend).toHaveBeenCalledOnce(); + expect(mockSend).toHaveBeenCalledWith(expect.any(ListObjectsV2Command)); + + const sentCommand = (mockSend.mock.calls[0][0] as ListObjectsV2Command).input; + expect(sentCommand.Bucket).toBe('test-bucket'); + expect(sentCommand.Prefix).toBe(`user-${userId}/`); + + expect(files).toHaveLength(2); + expect(files).toEqual([ + { key: `user-${userId}/file1.txt`, lastModified: new Date('2023-01-01') }, + { key: `user-${userId}/image.jpg`, lastModified: new Date('2023-01-02') }, + ]); + }); + + it("should return an empty array if the user has no files", async () => { + const mockS3Response = { + Contents: [], + }; + mockSend.mockResolvedValue(mockS3Response); + + const files = await s3Service.listFilesForUser(userId); + + expect(mockSend).toHaveBeenCalledOnce(); + expect(files).toEqual([]); + }); + + it("should return an empty array if the S3 response has no 'Contents' property", async () => { + const mockS3Response = {}; + mockSend.mockResolvedValue(mockS3Response); + + const files = await s3Service.listFilesForUser(userId); + + expect(mockSend).toHaveBeenCalledOnce(); + expect(files).toEqual([]); + }); + }); + + describe('deleteFile', () => { + it('should call the S3 client with the correct DeleteObjectCommand', async () => { + const objectKey = 'user-123/some-file-to-delete.txt'; + mockSend.mockResolvedValue({}); + + await expect(s3Service.deleteFile(objectKey)).resolves.toBeUndefined(); + + expect(mockSend).toHaveBeenCalledOnce(); + expect(mockSend).toHaveBeenCalledWith(expect.any(DeleteObjectCommand)); + + const sentCommand = (mockSend.mock.calls[0][0] as DeleteObjectCommand).input; + expect(sentCommand.Bucket).toBe('test-bucket'); + expect(sentCommand.Key).toBe(objectKey); + }); + + it('should throw an error if the S3 client fails to delete the object', async () => { + const objectKey = 'user-123/failing-file.txt'; + const s3Error = new Error('Access Denied'); + mockSend.mockRejectedValue(s3Error); + + await expect(s3Service.deleteFile(objectKey)).rejects.toThrow('Access Denied'); + }); + }); + + describe('getSignedDownloadUrl', () => { it('should generate a signed URL for a given object key', async () => { const objectKey = 'user-123/image.png';