package server import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/dhao2001/mygo/internal/app" "github.com/dhao2001/mygo/internal/auth" "github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" "github.com/dhao2001/mygo/internal/storage" ) const ( fileIntegrationJWTSecret = "test-secret" fileIntegrationMaxUploadSize = 20 << 20 ) type fileIntegrationFixture struct { router http.Handler jwtSecret []byte } type fileIntegrationTokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } type fileIntegrationInfo struct { ID string `json:"id"` Name string `json:"name"` Size int64 `json:"size"` } type fileIntegrationList struct { Files []fileIntegrationInfo `json:"files"` Total int64 `json:"total"` } func assertFileIntegrationNotFound( t *testing.T, rec *httptest.ResponseRecorder, wantMessage string, ) { t.Helper() if rec.Code != http.StatusNotFound { t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String()) } var response struct { Error struct { Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { t.Fatalf("decode error response: %v", err) } if response.Error.Message != wantMessage { t.Fatalf("message=%q, want %q", response.Error.Message, wantMessage) } if strings.Contains(rec.Body.String(), "access denied") { t.Fatalf("response leaks authorization result: %s", rec.Body.String()) } } func newFileIntegrationFixture(t *testing.T) *fileIntegrationFixture { t.Helper() dbCfg := config.DatabaseConfig{ Driver: "sqlite3", SQLite: config.SQLiteConfig{Path: ":memory:"}, } db, err := repository.Open(dbCfg) if err != nil { t.Fatalf("open db: %v", err) } if err := repository.AutoMigrate(db); err != nil { t.Fatalf("migrate: %v", err) } userRepo := repository.NewUserRepository(db) sessionRepo := repository.NewSessionRepository(db) fileRepo := repository.NewFileRepository(db) credentialRepo := repository.NewCredentialRepository(db) store, err := storage.NewLocalStorage(t.TempDir()) if err != nil { t.Fatalf("create local storage: %v", err) } jwtSecret := []byte(fileIntegrationJWTSecret) authService := service.NewAuthService( userRepo, sessionRepo, credentialRepo, jwtSecret, 15*time.Minute, 168*time.Hour, ) fileService := service.NewFileService(fileRepo, store, fileIntegrationMaxUploadSize, nil) cfg := &config.Config{ JWT: config.JWTConfig{ Secret: string(jwtSecret), AccessTTL: 15 * time.Minute, RefreshTTL: 168 * time.Hour, }, Storage: config.StorageConfig{MaxUploadSize: fileIntegrationMaxUploadSize}, } webApp := app.NewWebApp( cfg, db, userRepo, sessionRepo, fileRepo, credentialRepo, authService, service.NewAdminService(userRepo), fileService, store, ) t.Cleanup(func() { if err := webApp.Close(); err != nil { t.Errorf("close web app: %v", err) } }) return &fileIntegrationFixture{ router: NewRouter(webApp), jwtSecret: jwtSecret, } } func (f *fileIntegrationFixture) registerAndLogin( t *testing.T, username string, ) (string, fileIntegrationTokens) { t.Helper() email := username + "@example.com" password := "password123" registerBody, err := json.Marshal(map[string]string{ "username": username, "email": email, "password": password, }) if err != nil { t.Fatalf("marshal register body: %v", err) } registerRequest := httptest.NewRequest( http.MethodPost, "/api/v1/auth/register", bytes.NewReader(registerBody), ) registerRequest.Header.Set("Content-Type", "application/json") registerResponse := httptest.NewRecorder() f.router.ServeHTTP(registerResponse, registerRequest) if registerResponse.Code != http.StatusCreated { t.Fatalf("register %s: status=%d, body=%s", username, registerResponse.Code, registerResponse.Body.String()) } var registered struct { ID string `json:"id"` } if err := json.Unmarshal(registerResponse.Body.Bytes(), ®istered); err != nil { t.Fatalf("decode register response: %v", err) } if registered.ID == "" { t.Fatal("register response has empty user ID") } loginBody, err := json.Marshal(map[string]string{ "email": email, "password": password, }) if err != nil { t.Fatalf("marshal login body: %v", err) } loginRequest := httptest.NewRequest( http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody), ) loginRequest.Header.Set("Content-Type", "application/json") loginResponse := httptest.NewRecorder() f.router.ServeHTTP(loginResponse, loginRequest) if loginResponse.Code != http.StatusOK { t.Fatalf("login %s: status=%d, body=%s", username, loginResponse.Code, loginResponse.Body.String()) } var tokens fileIntegrationTokens if err := json.Unmarshal(loginResponse.Body.Bytes(), &tokens); err != nil { t.Fatalf("decode login response: %v", err) } if tokens.AccessToken == "" || tokens.RefreshToken == "" { t.Fatal("login response has an empty token") } return registered.ID, tokens } func newAuthenticatedFileRequest(method, target string, body io.Reader, token string) *http.Request { req := httptest.NewRequest(method, target, body) req.Header.Set("Authorization", "Bearer "+token) return req } func newFileUploadRequest( t *testing.T, target, token, fileName string, content []byte, ) *http.Request { t.Helper() body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("file", fileName) if err != nil { t.Fatalf("create multipart file: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write multipart file: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } req := newAuthenticatedFileRequest(http.MethodPost, target, body, token) req.Header.Set("Content-Type", writer.FormDataContentType()) return req } func (f *fileIntegrationFixture) uploadFile( t *testing.T, token, fileName string, content []byte, ) fileIntegrationInfo { t.Helper() req := newFileUploadRequest(t, "/api/v1/files", token, fileName, content) rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("upload %s: status=%d, body=%s", fileName, rec.Code, rec.Body.String()) } var uploaded fileIntegrationInfo if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil { t.Fatalf("decode upload response: %v", err) } return uploaded } func (f *fileIntegrationFixture) createDirectory( t *testing.T, token, name string, parentID *string, ) fileIntegrationInfo { t.Helper() body, err := json.Marshal(map[string]any{ "name": name, "parent_id": parentID, }) if err != nil { t.Fatalf("marshal create directory body: %v", err) } req := newAuthenticatedFileRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body), token) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("create directory %s: status=%d, body=%s", name, rec.Code, rec.Body.String()) } var created fileIntegrationInfo if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { t.Fatalf("decode create directory response: %v", err) } return created } func (f *fileIntegrationFixture) listFiles( t *testing.T, token, target string, ) fileIntegrationList { t.Helper() req := newAuthenticatedFileRequest(http.MethodGet, target, nil, token) rec := httptest.NewRecorder() f.router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list files: status=%d, body=%s", rec.Code, rec.Body.String()) } var list fileIntegrationList if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { t.Fatalf("decode file list: %v", err) } return list } func TestAuthenticatedFileFlow(t *testing.T) { fixture := newFileIntegrationFixture(t) _, tokens := fixture.registerAndLogin(t, "web-user") initialList := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files?offset=0&limit=50") if initialList.Total != 0 { t.Fatalf("initial total=%d, want 0", initialList.Total) } fileContent := []byte("MyGO browser milestone upload") uploadedFile := fixture.uploadFile(t, tokens.AccessToken, "milestone.txt", fileContent) if uploadedFile.Name != "milestone.txt" || uploadedFile.Size != int64(len(fileContent)) { t.Fatalf("uploaded file=%+v, want name milestone.txt and size %d", uploadedFile, len(fileContent)) } uploadedList := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files?offset=0&limit=50") if uploadedList.Total != 1 || len(uploadedList.Files) != 1 || uploadedList.Files[0].ID != uploadedFile.ID { t.Fatalf("uploaded list=%+v, want the uploaded file", uploadedList) } downloadRequest := newAuthenticatedFileRequest( http.MethodGet, "/api/v1/files/"+uploadedFile.ID+"/content", nil, tokens.AccessToken, ) downloadResponse := httptest.NewRecorder() fixture.router.ServeHTTP(downloadResponse, downloadRequest) if downloadResponse.Code != http.StatusOK { t.Fatalf("download: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String()) } if !bytes.Equal(downloadResponse.Body.Bytes(), fileContent) { t.Fatalf("downloaded content=%q, want %q", downloadResponse.Body.Bytes(), fileContent) } if disposition := downloadResponse.Header().Get("Content-Disposition"); disposition == "" { t.Fatal("download response has empty Content-Disposition") } } func TestFileRoutesRejectTestIdentityHeaders(t *testing.T) { fixture := newFileIntegrationFixture(t) routes := []struct { name string method string target string }{ {name: "list", method: http.MethodGet, target: "/api/v1/files"}, {name: "upload", method: http.MethodPost, target: "/api/v1/files"}, {name: "get", method: http.MethodGet, target: "/api/v1/files/file-id"}, {name: "download", method: http.MethodGet, target: "/api/v1/files/file-id/content"}, {name: "update", method: http.MethodPut, target: "/api/v1/files/file-id"}, {name: "delete", method: http.MethodDelete, target: "/api/v1/files/file-id"}, } for _, route := range routes { t.Run(route.name, func(t *testing.T) { req := httptest.NewRequest(route.method, route.target, nil) req.Header.Set("X-Test-User-ID", "attacker") req.Header.Set("X-User-ID", "attacker") rec := httptest.NewRecorder() fixture.router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } }) } } func TestFileRoutesRejectInvalidAccessTokens(t *testing.T) { fixture := newFileIntegrationFixture(t) userID, tokens := fixture.registerAndLogin(t, "token-user") wrongSignatureToken, err := auth.GenerateAccessToken(userID, []byte("wrong-secret"), 15*time.Minute) if err != nil { t.Fatalf("generate wrong-signature token: %v", err) } expiredToken, err := auth.GenerateAccessToken(userID, fixture.jwtSecret, -time.Minute) if err != nil { t.Fatalf("generate expired token: %v", err) } tests := []struct { name string token string }{ {name: "malformed", token: "not-a-jwt"}, {name: "wrong-signature", token: wrongSignatureToken}, {name: "expired", token: expiredToken}, {name: "refresh-token", token: tokens.RefreshToken}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := newFileUploadRequest(t, "/api/v1/files", tt.token, tt.name+".txt", []byte("must not persist")) rec := httptest.NewRecorder() fixture.router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } }) } list := fixture.listFiles(t, tokens.AccessToken, "/api/v1/files") if list.Total != 0 || len(list.Files) != 0 { t.Fatalf("files after rejected uploads=%+v, want empty list", list) } } func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) { fixture := newFileIntegrationFixture(t) _, ownerTokens := fixture.registerAndLogin(t, "owner") _, otherTokens := fixture.registerAndLogin(t, "other-user") privateDir := fixture.createDirectory(t, ownerTokens.AccessToken, "private", nil) fileContent := []byte("owner-only content") privateFile := fixture.uploadFile(t, ownerTokens.AccessToken, "owner.txt", fileContent) otherList := fixture.listFiles(t, otherTokens.AccessToken, "/api/v1/files") if otherList.Total != 0 || len(otherList.Files) != 0 { t.Fatalf("other user's root list=%+v, want empty list", otherList) } otherFile := fixture.uploadFile(t, otherTokens.AccessToken, "other.txt", []byte("other content")) createBody, err := json.Marshal(map[string]any{ "name": "intrusion", "parent_id": privateDir.ID, }) if err != nil { t.Fatalf("marshal cross-owner create body: %v", err) } moveBody, err := json.Marshal(map[string]any{"parent_id": privateDir.ID}) if err != nil { t.Fatalf("marshal cross-owner move body: %v", err) } parentRequests := []struct { name string newRequest func(*testing.T) *http.Request }{ { name: "list", newRequest: func(*testing.T) *http.Request { return newAuthenticatedFileRequest( http.MethodGet, "/api/v1/files?parent_id="+privateDir.ID, nil, otherTokens.AccessToken, ) }, }, { name: "create directory", newRequest: func(*testing.T) *http.Request { req := newAuthenticatedFileRequest( http.MethodPost, "/api/v1/files", bytes.NewReader(createBody), otherTokens.AccessToken, ) req.Header.Set("Content-Type", "application/json") return req }, }, { name: "upload", newRequest: func(t *testing.T) *http.Request { return newFileUploadRequest( t, "/api/v1/files?parent_id="+privateDir.ID, otherTokens.AccessToken, "intrusion.txt", []byte("must not persist"), ) }, }, { name: "move", newRequest: func(*testing.T) *http.Request { req := newAuthenticatedFileRequest( http.MethodPut, "/api/v1/files/"+otherFile.ID, bytes.NewReader(moveBody), otherTokens.AccessToken, ) req.Header.Set("Content-Type", "application/json") return req }, }, } for _, request := range parentRequests { t.Run("parent/"+request.name, func(t *testing.T) { rec := httptest.NewRecorder() fixture.router.ServeHTTP(rec, request.newRequest(t)) assertFileIntegrationNotFound(t, rec, "parent directory not found") }) } missingParentRequest := newAuthenticatedFileRequest( http.MethodGet, "/api/v1/files?parent_id=missing-directory", nil, otherTokens.AccessToken, ) missingParentResponse := httptest.NewRecorder() fixture.router.ServeHTTP(missingParentResponse, missingParentRequest) assertFileIntegrationNotFound(t, missingParentResponse, "parent directory not found") updateBody := []byte(`{"name":"stolen.txt"}`) requests := []struct { name string method string target string body []byte contentType string }{ {name: "get", method: http.MethodGet, target: "/api/v1/files/" + privateFile.ID}, {name: "download", method: http.MethodGet, target: "/api/v1/files/" + privateFile.ID + "/content"}, {name: "update", method: http.MethodPut, target: "/api/v1/files/" + privateFile.ID, body: updateBody, contentType: "application/json"}, {name: "delete", method: http.MethodDelete, target: "/api/v1/files/" + privateFile.ID}, } for _, request := range requests { t.Run(request.name, func(t *testing.T) { req := newAuthenticatedFileRequest( request.method, request.target, bytes.NewReader(request.body), otherTokens.AccessToken, ) if request.contentType != "" { req.Header.Set("Content-Type", request.contentType) } rec := httptest.NewRecorder() fixture.router.ServeHTTP(rec, req) assertFileIntegrationNotFound(t, rec, "file not found") }) } getRequest := newAuthenticatedFileRequest( http.MethodGet, "/api/v1/files/"+privateFile.ID, nil, ownerTokens.AccessToken, ) getResponse := httptest.NewRecorder() fixture.router.ServeHTTP(getResponse, getRequest) if getResponse.Code != http.StatusOK { t.Fatalf("owner get after rejected access: status=%d, body=%s", getResponse.Code, getResponse.Body.String()) } var retainedFile fileIntegrationInfo if err := json.Unmarshal(getResponse.Body.Bytes(), &retainedFile); err != nil { t.Fatalf("decode retained file: %v", err) } if retainedFile.Name != privateFile.Name { t.Fatalf("retained file name=%q, want %q", retainedFile.Name, privateFile.Name) } downloadRequest := newAuthenticatedFileRequest( http.MethodGet, "/api/v1/files/"+privateFile.ID+"/content", nil, ownerTokens.AccessToken, ) downloadResponse := httptest.NewRecorder() fixture.router.ServeHTTP(downloadResponse, downloadRequest) if downloadResponse.Code != http.StatusOK { t.Fatalf("owner download after rejected access: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String()) } if !bytes.Equal(downloadResponse.Body.Bytes(), fileContent) { t.Fatalf("retained file content=%q, want %q", downloadResponse.Body.Bytes(), fileContent) } dirList := fixture.listFiles( t, ownerTokens.AccessToken, "/api/v1/files?parent_id="+privateDir.ID, ) if dirList.Total != 0 || len(dirList.Files) != 0 { t.Fatalf("private directory after rejected create=%+v, want empty list", dirList) } otherRootList := fixture.listFiles(t, otherTokens.AccessToken, "/api/v1/files") if otherRootList.Total != 1 || len(otherRootList.Files) != 1 || otherRootList.Files[0].ID != otherFile.ID { t.Fatalf("other user's root list after rejected move=%+v, want original file", otherRootList) } } func TestFileDeleteReturnsNotFoundForMissingAndDeletedFiles(t *testing.T) { fixture := newFileIntegrationFixture(t) _, tokens := fixture.registerAndLogin(t, "delete-user") missingRequest := newAuthenticatedFileRequest( http.MethodDelete, "/api/v1/files/missing-file", nil, tokens.AccessToken, ) missingResponse := httptest.NewRecorder() fixture.router.ServeHTTP(missingResponse, missingRequest) assertFileIntegrationNotFound(t, missingResponse, "file not found") uploaded := fixture.uploadFile(t, tokens.AccessToken, "delete-me.txt", []byte("delete content")) deleteTarget := "/api/v1/files/" + uploaded.ID firstRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken) firstResponse := httptest.NewRecorder() fixture.router.ServeHTTP(firstResponse, firstRequest) if firstResponse.Code != http.StatusNoContent { t.Fatalf("first delete: status=%d, want %d; body=%s", firstResponse.Code, http.StatusNoContent, firstResponse.Body.String()) } secondRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken) secondResponse := httptest.NewRecorder() fixture.router.ServeHTTP(secondResponse, secondRequest) assertFileIntegrationNotFound(t, secondResponse, "file not found") }