// SPDX-License-Identifier: AGPL-3.0-or-later package api import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "testing" "kode.naiv.no/olemd/favoritter/internal/config" "kode.naiv.no/olemd/favoritter/internal/database" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/store" ) // testAPIServer creates a wired API handler with in-memory DB. func testAPIServer(t *testing.T) (*Handler, *http.ServeMux, *store.UserStore, *store.SessionStore) { t.Helper() db, err := database.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } if err := database.Migrate(db); err != nil { t.Fatalf("migrate: %v", err) } t.Cleanup(func() { db.Close() }) store.Argon2Memory = 1024 store.Argon2Time = 1 cfg := &config.Config{ MaxUploadSize: 10 << 20, // 10 MB } users := store.NewUserStore(db) sessions := store.NewSessionStore(db) faves := store.NewFaveStore(db) tags := store.NewTagStore(db) h := New(Deps{ Config: cfg, Users: users, Sessions: sessions, Faves: faves, Tags: tags, }) mux := http.NewServeMux() h.Routes(mux) // Wrap with SessionLoader so authenticated API requests work. chain := middleware.SessionLoader(sessions, users)(mux) wrappedMux := http.NewServeMux() wrappedMux.Handle("/", chain) return h, wrappedMux, users, sessions } // apiLogin creates a user and returns a session cookie. func apiLogin(t *testing.T, users *store.UserStore, sessions *store.SessionStore, username, password, role string) *http.Cookie { t.Helper() user, err := users.Create(username, password, role) if err != nil { t.Fatalf("create user %s: %v", username, err) } token, err := sessions.Create(user.ID) if err != nil { t.Fatalf("create session: %v", err) } return &http.Cookie{Name: "session", Value: token} } // jsonBody is a helper to parse JSON response bodies. func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any { t.Helper() var result map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { t.Fatalf("parse response JSON: %v\nbody: %s", err, rr.Body.String()) } return result } // --- Auth --- func TestAPILoginSuccess(t *testing.T) { _, mux, users, _ := testAPIServer(t) users.Create("testuser", "password123", "user") body := `{"username":"testuser","password":"password123"}` req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body)) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("login: got %d, want 200\nbody: %s", rr.Code, rr.Body.String()) } result := jsonBody(t, rr) if result["token"] == nil || result["token"] == "" { t.Error("expected token in response") } user, ok := result["user"].(map[string]any) if !ok { t.Fatal("expected user object in response") } if user["username"] != "testuser" { t.Errorf("username = %v, want testuser", user["username"]) } } func TestAPILoginWrongPassword(t *testing.T) { _, mux, users, _ := testAPIServer(t) users.Create("testuser", "password123", "user") body := `{"username":"testuser","password":"wrong"}` req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body)) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Errorf("wrong password: got %d, want 401", rr.Code) } } func TestAPILoginInvalidBody(t *testing.T) { _, mux, _, _ := testAPIServer(t) req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader("not json")) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("invalid body: got %d, want 400", rr.Code) } } func TestAPILogout(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("logout: got %d, want 200", rr.Code) } // Session should be invalid now. _, err := sessions.Validate(cookie.Value) if err == nil { t.Error("session should be invalidated after logout") } } // --- Faves CRUD --- func TestAPICreateFave(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") body := `{"description":"My favorite thing","url":"https://example.com","privacy":"public","tags":["go","web"]}` req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body)) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { t.Fatalf("create fave: got %d, want 201\nbody: %s", rr.Code, rr.Body.String()) } result := jsonBody(t, rr) if result["description"] != "My favorite thing" { t.Errorf("description = %v", result["description"]) } if result["url"] != "https://example.com" { t.Errorf("url = %v", result["url"]) } tags, ok := result["tags"].([]any) if !ok || len(tags) != 2 { t.Errorf("expected 2 tags, got %v", result["tags"]) } } func TestAPICreateFaveMissingDescription(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") body := `{"url":"https://example.com"}` req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body)) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("missing description: got %d, want 400", rr.Code) } } func TestAPICreateFaveRequiresAuth(t *testing.T) { _, mux, _, _ := testAPIServer(t) body := `{"description":"test"}` req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body)) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) // Should redirect or return non-2xx. if rr.Code == http.StatusCreated || rr.Code == http.StatusOK { t.Errorf("unauthenticated create: got %d, should not be 2xx", rr.Code) } } func TestAPIGetFave(t *testing.T) { h, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") // Create a public fave directly. user, _ := users.GetByUsername("testuser") fave, _ := h.deps.Faves.Create(user.ID, "Test fave", "https://example.com", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"test"}) req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("get fave: got %d, want 200", rr.Code) } result := jsonBody(t, rr) if result["description"] != "Test fave" { t.Errorf("description = %v", result["description"]) } } func TestAPIGetFaveNotFound(t *testing.T) { _, mux, _, _ := testAPIServer(t) req := httptest.NewRequest("GET", "/api/v1/faves/99999", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("nonexistent fave: got %d, want 404", rr.Code) } } func TestAPIPrivateFaveHiddenFromOthers(t *testing.T) { h, mux, users, sessions := testAPIServer(t) // User A creates a private fave. userA, _ := users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "Secret", "", "", "private") // User B tries to access it. cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user") req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookieB) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("private fave for other user: got %d, want 404", rr.Code) } } func TestAPIPrivateFaveVisibleToOwner(t *testing.T) { h, mux, users, sessions := testAPIServer(t) userA, _ := users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private") tokenA, _ := sessions.Create(userA.ID) cookieA := &http.Cookie{Name: "session", Value: tokenA} req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookieA) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("own private fave: got %d, want 200", rr.Code) } } func TestAPIUpdateFave(t *testing.T) { h, mux, users, sessions := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Original", "https://old.com", "", "public") token, _ := sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} body := `{"description":"Updated","url":"https://new.com","tags":["updated"]}` req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body)) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("update fave: got %d, want 200\nbody: %s", rr.Code, rr.Body.String()) } result := jsonBody(t, rr) if result["description"] != "Updated" { t.Errorf("description = %v, want Updated", result["description"]) } if result["url"] != "https://new.com" { t.Errorf("url = %v, want https://new.com", result["url"]) } } func TestAPIUpdateFaveNotOwner(t *testing.T) { h, mux, users, sessions := testAPIServer(t) userA, _ := users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public") cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user") body := `{"description":"Hijacked"}` req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body)) req.AddCookie(cookieB) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("update by non-owner: got %d, want 403", rr.Code) } } func TestAPIDeleteFave(t *testing.T) { h, mux, users, sessions := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "public") token, _ := sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNoContent { t.Errorf("delete fave: got %d, want 204", rr.Code) } // Verify it's gone. req = httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookie) rr = httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("deleted fave: got %d, want 404", rr.Code) } } func TestAPIDeleteFaveNotOwner(t *testing.T) { h, mux, users, sessions := testAPIServer(t) userA, _ := users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public") cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user") req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil) req.AddCookie(cookieB) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("delete by non-owner: got %d, want 403", rr.Code) } } func TestAPIListFaves(t *testing.T) { h, mux, users, sessions := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public") h.deps.Faves.Create(user.ID, "Fave 2", "", "", "public") h.deps.Faves.Create(user.ID, "Fave 3", "", "", "private") token, _ := sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} req := httptest.NewRequest("GET", "/api/v1/faves", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("list faves: got %d, want 200", rr.Code) } result := jsonBody(t, rr) total, _ := result["total"].(float64) if total != 3 { t.Errorf("total = %v, want 3 (all faves including private)", total) } } func TestAPIListFavesPagination(t *testing.T) { h, mux, users, sessions := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") for i := 0; i < 5; i++ { h.deps.Faves.Create(user.ID, "Fave", "", "", "public") } token, _ := sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} req := httptest.NewRequest("GET", "/api/v1/faves?page=1&limit=2", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) result := jsonBody(t, rr) faves, ok := result["faves"].([]any) if !ok { t.Fatal("expected faves array") } if len(faves) != 2 { t.Errorf("page size: got %d faves, want 2", len(faves)) } total, _ := result["total"].(float64) if total != 5 { t.Errorf("total = %v, want 5", total) } } // --- Tags --- func TestAPISearchTags(t *testing.T) { h, mux, users, _ := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines", "python"}) req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("search tags: got %d, want 200", rr.Code) } result := jsonBody(t, rr) tags, ok := result["tags"].([]any) if !ok { t.Fatal("expected tags array") } if len(tags) < 1 { t.Error("expected at least one tag matching 'go'") } } func TestAPISearchTagsEmpty(t *testing.T) { _, mux, _, _ := testAPIServer(t) req := httptest.NewRequest("GET", "/api/v1/tags?q=nonexistent", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("empty tag search: got %d, want 200", rr.Code) } result := jsonBody(t, rr) tags, _ := result["tags"].([]any) if len(tags) != 0 { t.Errorf("expected empty tags, got %v", tags) } } // --- Users --- func TestAPIGetUser(t *testing.T) { _, mux, users, _ := testAPIServer(t) users.Create("testuser", "pass123", "user") req := httptest.NewRequest("GET", "/api/v1/users/testuser", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("get user: got %d, want 200", rr.Code) } result := jsonBody(t, rr) if result["username"] != "testuser" { t.Errorf("username = %v", result["username"]) } } func TestAPIGetUserNotFound(t *testing.T) { _, mux, _, _ := testAPIServer(t) req := httptest.NewRequest("GET", "/api/v1/users/nobody", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("nonexistent user: got %d, want 404", rr.Code) } } func TestAPIGetDisabledUser(t *testing.T) { _, mux, users, _ := testAPIServer(t) user, _ := users.Create("disabled", "pass123", "user") users.SetDisabled(user.ID, true) req := httptest.NewRequest("GET", "/api/v1/users/disabled", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("disabled user: got %d, want 404", rr.Code) } } func TestAPIGetUserFaves(t *testing.T) { h, mux, users, _ := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") h.deps.Faves.Create(user.ID, "Public fave", "", "", "public") h.deps.Faves.Create(user.ID, "Private fave", "", "", "private") req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("user faves: got %d, want 200", rr.Code) } result := jsonBody(t, rr) total, _ := result["total"].(float64) if total != 1 { t.Errorf("total = %v, want 1 (only public faves)", total) } } // --- Export/Import --- func TestAPIExport(t *testing.T) { h, mux, users, sessions := testAPIServer(t) user, _ := users.Create("testuser", "pass123", "user") h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public") h.deps.Faves.Create(user.ID, "Fave 2", "", "", "private") token, _ := sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} req := httptest.NewRequest("GET", "/api/v1/export/json", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("export: got %d, want 200", rr.Code) } // Export returns a JSON array directly. var faves []any if err := json.Unmarshal(rr.Body.Bytes(), &faves); err != nil { t.Fatalf("parse export JSON: %v", err) } if len(faves) != 2 { t.Errorf("exported %d faves, want 2", len(faves)) } } func TestAPIImportValid(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") body := `[{"description":"Imported 1","privacy":"public"},{"description":"Imported 2","tags":["test"]}]` req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body)) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("import: got %d, want 200\nbody: %s", rr.Code, rr.Body.String()) } result := jsonBody(t, rr) imported, _ := result["imported"].(float64) if imported != 2 { t.Errorf("imported = %v, want 2", imported) } } func TestAPIImportSkipsEmpty(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") body := `[{"description":"Valid"},{"description":"","url":"https://empty.com"}]` req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body)) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) result := jsonBody(t, rr) imported, _ := result["imported"].(float64) total, _ := result["total"].(float64) if imported != 1 { t.Errorf("imported = %v, want 1", imported) } if total != 2 { t.Errorf("total = %v, want 2", total) } } func TestAPIImportInvalidJSON(t *testing.T) { _, mux, users, sessions := testAPIServer(t) cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user") req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader("not json")) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Errorf("invalid JSON import: got %d, want 400", rr.Code) } } // --- JSON helpers --- func TestQueryIntFallback(t *testing.T) { tests := []struct { query string want int }{ {"", 10}, {"page=abc", 10}, {"page=-1", 10}, {"page=0", 10}, {"page=5", 5}, } for _, tt := range tests { req := httptest.NewRequest("GET", "/test?"+tt.query, nil) got := queryInt(req, "page", 10) if got != tt.want { t.Errorf("queryInt(%q) = %d, want %d", tt.query, got, tt.want) } } } // faveIDStr converts an int64 to a string for URL paths. func faveIDStr(id int64) string { return strconv.FormatInt(id, 10) }