diff --git a/docs/server/roadmap.md b/docs/server/roadmap.md index c79db6f..9264a9e 100644 --- a/docs/server/roadmap.md +++ b/docs/server/roadmap.md @@ -27,7 +27,7 @@ Package-level implementation order (each task includes unit tests): 10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place) 11. `internal/server` — Gin router, route registration, graceful shutdown ✅ 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done) -13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file) +13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file; file API authentication bypass, invalid token, and cross-user isolation boundaries covered) 14. Architecture boundary tests ✅ ## Future diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 2701936..8b1bddb 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -22,6 +22,8 @@ import ( "github.com/dhao2001/mygo/internal/storage" ) +const testUserIDHeader = "X-Test-User-ID" + // inMemStore implements storage.StorageBackend with an in-memory map. type inMemStore struct { files map[string][]byte @@ -130,9 +132,9 @@ func setupFileRouterWithMaxUploadSizeAndStore(t *testing.T, maxUploadSize int64) gin.SetMode(gin.TestMode) r := gin.New() - // Middleware that injects user_id from header (simulates AuthRequired). + // Test-only middleware that injects user_id to simulate a successful AuthRequired call. r.Use(func(c *gin.Context) { - userID := c.GetHeader("X-User-ID") + userID := c.GetHeader(testUserIDHeader) if userID != "" { c.Set("user_id", userID) } @@ -163,7 +165,7 @@ func TestFileHandler_Upload(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -191,7 +193,7 @@ func TestFileHandler_UploadRejectsOversizedFile(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -211,7 +213,7 @@ func TestFileHandler_UploadAllowsExactMaxUploadSize(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -231,7 +233,7 @@ func TestFileHandler_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -249,7 +251,7 @@ func TestFileHandler_UploadNoFile(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -269,7 +271,7 @@ func TestFileHandler_UploadUnexpectedFieldDoesNotEchoFieldName(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -289,7 +291,7 @@ func TestFileHandler_UploadMalformedMultipartDoesNotLeakParserError(t *testing.T req := httptest.NewRequest(http.MethodPost, "/api/v1/files", strings.NewReader("not a valid multipart body")) req.Header.Set("Content-Type", "multipart/form-data; boundary=mygo-boundary") - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -310,7 +312,7 @@ func TestFileHandler_CreateDir(t *testing.T) { body, _ := json.Marshal(gin.H{"name": "mydir"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -339,13 +341,13 @@ func TestFileHandler_List(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) // List files. req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -374,7 +376,7 @@ func TestFileHandler_Get(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -383,7 +385,7 @@ func TestFileHandler_Get(t *testing.T) { // Get metadata. req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -404,7 +406,7 @@ func TestFileHandler_GetNotFound(t *testing.T) { r := setupFileRouter(t) req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -426,7 +428,7 @@ func TestFileHandler_Download(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -435,7 +437,7 @@ func TestFileHandler_Download(t *testing.T) { // Download. req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -462,7 +464,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -474,7 +476,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) { store.failReads = true req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -498,7 +500,7 @@ func TestFileHandler_Update(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -509,7 +511,7 @@ func TestFileHandler_Update(t *testing.T) { updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"}) req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -538,7 +540,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -548,7 +550,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) { updateBody, _ := json.Marshal(gin.H{}) req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -569,7 +571,7 @@ func TestFileHandler_Delete(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -578,7 +580,7 @@ func TestFileHandler_Delete(t *testing.T) { // Delete. req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -588,7 +590,7 @@ func TestFileHandler_Delete(t *testing.T) { // Verify gone. req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -609,7 +611,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body) req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("X-User-ID", "user1") + req.Header.Set(testUserIDHeader, "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -618,7 +620,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) { // Try to access as user2. req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil) - req.Header.Set("X-User-ID", "user2") + req.Header.Set(testUserIDHeader, "user2") rec = httptest.NewRecorder() r.ServeHTTP(rec, req) diff --git a/internal/server/file_integration_test.go b/internal/server/file_integration_test.go new file mode 100644 index 0000000..dee55e0 --- /dev/null +++ b/internal/server/file_integration_test.go @@ -0,0 +1,502 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "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 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) + } + + 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) + } + createRequest := newAuthenticatedFileRequest( + http.MethodPost, + "/api/v1/files", + bytes.NewReader(createBody), + otherTokens.AccessToken, + ) + createRequest.Header.Set("Content-Type", "application/json") + createResponse := httptest.NewRecorder() + fixture.router.ServeHTTP(createResponse, createRequest) + if createResponse.Code != http.StatusForbidden { + t.Fatalf("cross-owner create: status=%d, want %d; body=%s", createResponse.Code, http.StatusForbidden, createResponse.Body.String()) + } + + 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) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) + } + }) + } + + 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) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8e7d170..12dd639 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4,8 +4,6 @@ import ( "bytes" "context" "encoding/json" - "io" - "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -16,7 +14,6 @@ import ( "github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" - "github.com/dhao2001/mygo/internal/storage" ) func TestVersionRoute(t *testing.T) { @@ -184,192 +181,3 @@ func TestAdminRoutes(t *testing.T) { t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } } - -func TestAuthenticatedFileFlow(t *testing.T) { - 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) - } - - const maxUploadSize = 20 << 20 - jwtSecret := []byte("test-secret") - authService := service.NewAuthService( - userRepo, - sessionRepo, - credentialRepo, - jwtSecret, - 15*time.Minute, - 168*time.Hour, - ) - fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil) - cfg := &config.Config{ - JWT: config.JWTConfig{ - Secret: string(jwtSecret), - AccessTTL: 15 * time.Minute, - RefreshTTL: 168 * time.Hour, - }, - Storage: config.StorageConfig{MaxUploadSize: maxUploadSize}, - } - 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) - } - }) - router := NewRouter(webApp) - - registerBody := []byte(`{"username":"web-user","email":"web@example.com","password":"password123"}`) - registerRequest := httptest.NewRequest( - http.MethodPost, - "/api/v1/auth/register", - bytes.NewReader(registerBody), - ) - registerRequest.Header.Set("Content-Type", "application/json") - registerResponse := httptest.NewRecorder() - router.ServeHTTP(registerResponse, registerRequest) - if registerResponse.Code != http.StatusCreated { - t.Fatalf("register: status=%d, body=%s", registerResponse.Code, registerResponse.Body.String()) - } - - loginBody := []byte(`{"email":"web@example.com","password":"password123"}`) - loginRequest := httptest.NewRequest( - http.MethodPost, - "/api/v1/auth/login", - bytes.NewReader(loginBody), - ) - loginRequest.Header.Set("Content-Type", "application/json") - loginResponse := httptest.NewRecorder() - router.ServeHTTP(loginResponse, loginRequest) - if loginResponse.Code != http.StatusOK { - t.Fatalf("login: status=%d, body=%s", loginResponse.Code, loginResponse.Body.String()) - } - var tokens struct { - AccessToken string `json:"access_token"` - } - if err := json.Unmarshal(loginResponse.Body.Bytes(), &tokens); err != nil { - t.Fatalf("decode login response: %v", err) - } - if tokens.AccessToken == "" { - t.Fatal("login response has empty access token") - } - - listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil) - listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - listResponse := httptest.NewRecorder() - router.ServeHTTP(listResponse, listRequest) - if listResponse.Code != http.StatusOK { - t.Fatalf("initial list: status=%d, body=%s", listResponse.Code, listResponse.Body.String()) - } - var initialList struct { - Total int64 `json:"total"` - } - if err := json.Unmarshal(listResponse.Body.Bytes(), &initialList); err != nil { - t.Fatalf("decode initial list: %v", err) - } - if initialList.Total != 0 { - t.Fatalf("initial total=%d, want 0", initialList.Total) - } - - fileContent := []byte("MyGO browser milestone upload") - uploadBody := &bytes.Buffer{} - multipartWriter := multipart.NewWriter(uploadBody) - filePart, err := multipartWriter.CreateFormFile("file", "milestone.txt") - if err != nil { - t.Fatalf("create multipart file: %v", err) - } - if _, err := filePart.Write(fileContent); err != nil { - t.Fatalf("write multipart file: %v", err) - } - if err := multipartWriter.Close(); err != nil { - t.Fatalf("close multipart writer: %v", err) - } - uploadRequest := httptest.NewRequest(http.MethodPost, "/api/v1/files", uploadBody) - uploadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - uploadRequest.Header.Set("Content-Type", multipartWriter.FormDataContentType()) - uploadResponse := httptest.NewRecorder() - router.ServeHTTP(uploadResponse, uploadRequest) - if uploadResponse.Code != http.StatusCreated { - t.Fatalf("upload: status=%d, body=%s", uploadResponse.Code, uploadResponse.Body.String()) - } - var uploadedFile struct { - ID string `json:"id"` - Name string `json:"name"` - Size int64 `json:"size"` - } - if err := json.Unmarshal(uploadResponse.Body.Bytes(), &uploadedFile); err != nil { - t.Fatalf("decode upload response: %v", err) - } - 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)) - } - - listRequest = httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil) - listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - listResponse = httptest.NewRecorder() - router.ServeHTTP(listResponse, listRequest) - if listResponse.Code != http.StatusOK { - t.Fatalf("list after upload: status=%d, body=%s", listResponse.Code, listResponse.Body.String()) - } - var uploadedList struct { - Files []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"files"` - Total int64 `json:"total"` - } - if err := json.Unmarshal(listResponse.Body.Bytes(), &uploadedList); err != nil { - t.Fatalf("decode uploaded list: %v", err) - } - 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 := httptest.NewRequest( - http.MethodGet, - "/api/v1/files/"+uploadedFile.ID+"/content", - nil, - ) - downloadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) - downloadResponse := httptest.NewRecorder() - router.ServeHTTP(downloadResponse, downloadRequest) - if downloadResponse.Code != http.StatusOK { - t.Fatalf("download: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String()) - } - downloadedContent, err := io.ReadAll(downloadResponse.Body) - if err != nil { - t.Fatalf("read download response: %v", err) - } - if !bytes.Equal(downloadedContent, fileContent) { - t.Fatalf("downloaded content=%q, want %q", downloadedContent, fileContent) - } - if disposition := downloadResponse.Header().Get("Content-Disposition"); disposition == "" { - t.Fatal("download response has empty Content-Disposition") - } -}