// SPDX-License-Identifier: AGPL-3.0-or-later package handler import ( "bytes" "encoding/csv" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // csrfToken performs a GET to extract a CSRF token cookie from the response. func csrfToken(t *testing.T, mux *http.ServeMux, path string) string { t.Helper() req := httptest.NewRequest("GET", path, nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) token := extractCookie(rr, "csrf_token") if token == "" { t.Fatal("no csrf_token cookie from GET " + path) } return token } // postForm creates a POST request with form data and CSRF token. func postForm(path string, csrf string, values url.Values, cookies ...*http.Cookie) *http.Request { values.Set("csrf_token", csrf) req := httptest.NewRequest("POST", path, strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) for _, c := range cookies { req.AddCookie(c) } return req } // --- Auth flows --- func TestSignupOpenMode(t *testing.T) { h, mux := testServer(t) // Set signup mode to open. h.deps.Settings.Update("Test", "", "open") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"newuser"}, "password": {"password123"}, "password_confirm": {"password123"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Errorf("signup: got %d, want 303\nbody: %s", rr.Code, rr.Body.String()) } // Should have a session cookie (auto-login after signup). if extractCookie(rr, "session") == "" { t.Error("expected session cookie after signup") } } func TestSignupDuplicate(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "open") h.deps.Users.Create("existing", "password123", "user") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"existing"}, "password": {"password123"}, "password_confirm": {"password123"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("duplicate signup: got %d, want 200 (re-render)", rr.Code) } if !strings.Contains(rr.Body.String(), "allerede i bruk") { t.Error("should show duplicate username error") } } func TestSignupClosedMode(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "closed") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"newuser"}, "password": {"password123"}, "password_confirm": {"password123"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "stengt") { t.Error("closed signup should show 'stengt' message") } } func TestSignupRequestMode(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "requests") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"requester"}, "password": {"password123"}, "password_confirm": {"password123"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "sendt") { t.Error("request mode signup should show 'sendt' message") } // Verify a signup request was created. requests, _ := h.deps.SignupRequests.ListPending() if len(requests) != 1 { t.Errorf("expected 1 pending request, got %d", len(requests)) } } func TestSignupInvalidUsername(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "open") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"a"}, // Too short (min 2). "password": {"password123"}, "password_confirm": {"password123"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "Ugyldig brukernavn") { t.Error("should show username validation error") } } func TestSignupPasswordTooShort(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "open") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"newuser"}, "password": {"short"}, "password_confirm": {"short"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "minst 8 tegn") { t.Error("should show password length error") } } func TestSignupPasswordMismatch(t *testing.T) { h, mux := testServer(t) h.deps.Settings.Update("Test", "", "open") csrf := csrfToken(t, mux, "/signup") form := url.Values{ "username": {"newuser"}, "password": {"password123"}, "password_confirm": {"different456"}, } req := postForm("/signup", csrf, form) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "ikke like") { t.Error("should show password mismatch error") } } func TestLogout(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") csrf := csrfToken(t, mux, "/login") req := postForm("/logout", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Errorf("logout: got %d, want 303", rr.Code) } loc := rr.Header().Get("Location") if !strings.Contains(loc, "/login") { t.Errorf("logout redirect = %q, want /login", loc) } } func TestPasswordResetFlow(t *testing.T) { h, mux := testServer(t) // Create user with must_reset flag. user, _ := h.deps.Users.CreateWithReset("resetuser", "temppass", "user") token, _ := h.deps.Sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} // Accessing /reset-password should render the form. req := httptest.NewRequest("GET", "/reset-password", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("reset page: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Endre passord") { t.Error("should render password reset form") } } func TestPasswordResetSubmit(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.CreateWithReset("resetuser", "temppass", "user") token, _ := h.deps.Sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} // Get CSRF token. getReq := httptest.NewRequest("GET", "/reset-password", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{ "password": {"newpassword123"}, "password_confirm": {"newpassword123"}, } req := postForm("/reset-password", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Errorf("reset submit: got %d, want 303\nbody: %s", rr.Code, rr.Body.String()) } // Verify new password works. _, err := h.deps.Users.Authenticate("resetuser", "newpassword123") if err != nil { t.Errorf("new password should work: %v", err) } } // --- Fave CRUD (web) --- func TestCreateFavePageRendering(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") req := httptest.NewRequest("GET", "/faves/new", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("new fave page: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Ny favoritt") { t.Error("should render new fave form") } } func TestCreateFaveSubmit(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") // Get CSRF token. getReq := httptest.NewRequest("GET", "/faves/new", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") // POST multipart form (fave creation uses ParseMultipartForm). var body bytes.Buffer writer := multipart.NewWriter(&body) writer.WriteField("csrf_token", csrf) writer.WriteField("description", "Min nye favoritt") writer.WriteField("url", "https://example.com") writer.WriteField("privacy", "public") writer.WriteField("tags", "test, go") writer.Close() req := httptest.NewRequest("POST", "/faves", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Errorf("create fave: got %d, want 303\nbody: %s", rr.Code, rr.Body.String()) } // Verify redirect points to the new fave. loc := rr.Header().Get("Location") if !strings.Contains(loc, "/faves/") { t.Errorf("redirect = %q, should point to /faves/{id}", loc) } } func TestCreateFaveMissingDescription(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/faves/new", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") var body bytes.Buffer writer := multipart.NewWriter(&body) writer.WriteField("csrf_token", csrf) writer.WriteField("description", "") writer.Close() req := httptest.NewRequest("POST", "/faves", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "Beskrivelse er påkrevd") { t.Error("should show description required error") } } func TestEditFaveNotOwner(t *testing.T) { h, mux := testServer(t) userA, _ := h.deps.Users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public") cookieB := loginUser(t, h, "userb", "pass123", "user") req := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID)+"/edit", nil) req.AddCookie(cookieB) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("edit by non-owner: got %d, want 403", rr.Code) } } func TestDeleteFaveHTMX(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "", "public") token, _ := h.deps.Sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := httptest.NewRequest("DELETE", "/faves/"+faveIDStr(fave.ID), nil) req.Header.Set("HX-Request", "true") req.Header.Set("X-CSRF-Token", csrf) req.AddCookie(cookie) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("HTMX delete: got %d, want 200", rr.Code) } } func TestDeleteFaveNotOwner(t *testing.T) { h, mux := testServer(t) userA, _ := h.deps.Users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public") cookieB := loginUser(t, h, "userb", "pass123", "user") getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) getReq.AddCookie(cookieB) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := httptest.NewRequest("DELETE", "/faves/"+faveIDStr(fave.ID), nil) req.Header.Set("X-CSRF-Token", csrf) req.AddCookie(cookieB) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("delete by non-owner: got %d, want 403", rr.Code) } } // --- Admin --- func TestAdminUserList(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") h.deps.Users.Create("regular", "pass123", "user") req := httptest.NewRequest("GET", "/admin/users", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("admin users: got %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "admin") || !strings.Contains(body, "regular") { t.Error("admin users page should list all users") } } func TestAdminCreateUser(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{ "username": {"newuser"}, "role": {"user"}, } req := postForm("/admin/users", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "Bruker opprettet") { t.Error("should show user created message") } // Verify user was created. _, err := h.deps.Users.GetByUsername("newuser") if err != nil { t.Errorf("new user should exist: %v", err) } } func TestAdminToggleDisabled(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") user, _ := h.deps.Users.Create("target", "pass123", "user") getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/admin/users/"+faveIDStr(user.ID)+"/toggle-disabled", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "deaktivert") { t.Error("should show deactivated message") } } func TestAdminCannotDisableSelf(t *testing.T) { h, mux := testServer(t) admin, _ := h.deps.Users.Create("admin", "pass123", "admin") token, _ := h.deps.Sessions.Create(admin.ID) cookie := &http.Cookie{Name: "session", Value: token} getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/toggle-disabled", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "din egen konto") { t.Error("should prevent self-disable") } } func TestAdminSettings(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") // GET settings page. getReq := httptest.NewRequest("GET", "/admin/settings", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) if getRR.Code != http.StatusOK { t.Errorf("admin settings GET: got %d, want 200", getRR.Code) } csrf := extractCookie(getRR, "csrf_token") // POST updated settings. form := url.Values{ "site_name": {"Nytt Navn"}, "site_description": {"En beskrivelse"}, "signup_mode": {"closed"}, } req := postForm("/admin/settings", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "lagret") { t.Error("should show settings saved message") } // Verify settings were updated. settings, _ := h.deps.Settings.Get() if settings.SiteName != "Nytt Navn" { t.Errorf("site_name = %q, want 'Nytt Navn'", settings.SiteName) } } func TestAdminApproveSignupRequest(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") // Create a signup request. h.deps.SignupRequests.Create("requester", "password123") requests, _ := h.deps.SignupRequests.ListPending() if len(requests) == 0 { t.Fatal("expected pending request") } getReq := httptest.NewRequest("GET", "/admin/signup-requests", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{"action": {"approve"}} req := postForm("/admin/signup-requests/"+faveIDStr(requests[0].ID), csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "godkjent") { t.Error("should show approved message") } // User should now exist. _, err := h.deps.Users.GetByUsername("requester") if err != nil { t.Errorf("approved user should exist: %v", err) } } func TestAdminRejectSignupRequest(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") h.deps.SignupRequests.Create("rejectable", "password123") requests, _ := h.deps.SignupRequests.ListPending() getReq := httptest.NewRequest("GET", "/admin/signup-requests", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{"action": {"reject"}} req := postForm("/admin/signup-requests/"+faveIDStr(requests[0].ID), csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "avvist") { t.Error("should show rejected message") } } func TestAdminTags(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") // Create a tag via a fave. admin, _ := h.deps.Users.GetByUsername("admin") fave, _ := h.deps.Faves.Create(admin.ID, "Test", "", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"testmerke"}) req := httptest.NewRequest("GET", "/admin/tags", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("admin tags: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "testmerke") { t.Error("admin tags page should list tags") } } // --- Feeds --- func TestUserFeed(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("feeduser", "pass123", "user") h.deps.Users.UpdateProfile(user.ID, "Feed User", "", "public", "public") h.deps.Faves.Create(user.ID, "User fave", "", "", "", "public") req := httptest.NewRequest("GET", "/u/feeduser/feed.xml", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("user feed: got %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.Contains(ct, "atom+xml") { t.Errorf("content-type = %q", ct) } if !strings.Contains(rr.Body.String(), "User fave") { t.Error("user feed should contain fave") } } func TestFeedExcludesPrivate(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public") h.deps.Faves.Create(user.ID, "Secret fave", "", "", "", "private") req := httptest.NewRequest("GET", "/feed.xml", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "Public fave") { t.Error("feed should contain public fave") } if strings.Contains(body, "Secret fave") { t.Error("feed should NOT contain private fave") } } func TestTagFeed(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Tagged fave", "", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"}) req := httptest.NewRequest("GET", "/tags/golang/feed.xml", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("tag feed: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Tagged fave") { t.Error("tag feed should contain tagged fave") } } func TestLimitedProfileFeedReturns404(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("limited", "pass123", "user") h.deps.Users.UpdateProfile(user.ID, "Limited", "", "limited", "public") req := httptest.NewRequest("GET", "/u/limited/feed.xml", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("limited profile feed: got %d, want 404", rr.Code) } } // --- Import/Export (web) --- func TestExportJSON(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") user, _ := h.deps.Users.GetByUsername("testuser") fave, _ := h.deps.Faves.Create(user.ID, "Export me", "https://example.com", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"test"}) req := httptest.NewRequest("GET", "/export/json", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("export JSON: got %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.Contains(ct, "application/json") { t.Errorf("content-type = %q", ct) } var faves []ExportFave if err := json.Unmarshal(rr.Body.Bytes(), &faves); err != nil { t.Fatalf("parse export: %v", err) } if len(faves) != 1 { t.Fatalf("exported %d, want 1", len(faves)) } if faves[0].Description != "Export me" { t.Errorf("description = %q", faves[0].Description) } } func TestExportCSV(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") user, _ := h.deps.Users.GetByUsername("testuser") h.deps.Faves.Create(user.ID, "CSV fave", "", "", "", "public") req := httptest.NewRequest("GET", "/export/csv", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("export CSV: got %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.Contains(ct, "text/csv") { t.Errorf("content-type = %q", ct) } reader := csv.NewReader(bytes.NewReader(rr.Body.Bytes())) records, err := reader.ReadAll() if err != nil { t.Fatalf("parse CSV: %v", err) } // Header + 1 data row. if len(records) != 2 { t.Errorf("CSV rows = %d, want 2 (header + data)", len(records)) } if records[0][0] != "description" { t.Errorf("CSV header = %q, want 'description'", records[0][0]) } } func TestImportJSONFile(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/import", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") // Build multipart form with JSON file. var body bytes.Buffer writer := multipart.NewWriter(&body) writer.WriteField("csrf_token", csrf) part, _ := writer.CreateFormFile("file", "import.json") jsonData := `[{"description":"Imported 1","privacy":"public","tags":["go"]},{"description":"Imported 2"}]` part.Write([]byte(jsonData)) writer.Close() req := httptest.NewRequest("POST", "/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "Importert 2 av 2") { t.Errorf("import result: %s", rr.Body.String()) } } func TestImportCSVFile(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/import", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") // Build multipart form with CSV file. var body bytes.Buffer writer := multipart.NewWriter(&body) writer.WriteField("csrf_token", csrf) part, _ := writer.CreateFormFile("file", "import.csv") csvData := "description,url,privacy,tags\nCSV Fave,https://example.com,public,test\n" part.Write([]byte(csvData)) writer.Close() req := httptest.NewRequest("POST", "/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(cookie) req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf}) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "Importert 1 av 1") { t.Errorf("import CSV result: %s", rr.Body.String()) } } // --- Profile --- func TestProfileDisabledUser(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("disabled", "pass123", "user") h.deps.Users.SetDisabled(user.ID, true) req := httptest.NewRequest("GET", "/u/disabled", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusNotFound { t.Errorf("disabled profile: got %d, want 404", rr.Code) } } func TestProfileOwnerSeesPrivateFaves(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("owner", "pass123", "user") h.deps.Faves.Create(user.ID, "Private fave", "", "", "", "private") token, _ := h.deps.Sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} req := httptest.NewRequest("GET", "/u/owner", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("own profile: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Private fave") { t.Error("owner should see their own private faves on profile") } } func TestProfileVisitorCannotSeePrivateFaves(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("owner", "pass123", "user") h.deps.Users.UpdateProfile(user.ID, "Owner", "", "public", "public") h.deps.Faves.Create(user.ID, "Only public", "", "", "", "public") h.deps.Faves.Create(user.ID, "Hidden secret", "", "", "", "private") req := httptest.NewRequest("GET", "/u/owner", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "Only public") { t.Error("visitor should see public fave") } if strings.Contains(body, "Hidden secret") { t.Error("visitor should NOT see private fave") } } // --- Tag browsing --- func TestTagBrowse(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Tagged", "", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"}) req := httptest.NewRequest("GET", "/tags/golang", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("tag browse: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Tagged") { t.Error("tag browse should show tagged fave") } } // --- Home page --- func TestHomePageUnauthenticated(t *testing.T) { _, mux := testServer(t) req := httptest.NewRequest("GET", "/", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("home page: got %d, want 200", rr.Code) } } func TestHomePageAuthenticated(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") user, _ := h.deps.Users.GetByUsername("testuser") h.deps.Faves.Create(user.ID, "Home fave", "", "", "", "public") req := httptest.NewRequest("GET", "/", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("authenticated home: got %d, want 200", rr.Code) } } // --- Settings --- func TestSettingsPageRendering(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") req := httptest.NewRequest("GET", "/settings", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("settings page: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Innstillinger") { t.Error("should render settings page") } } func TestSettingsUpdateProfile(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/settings", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{ "display_name": {"Nytt Navn"}, "bio": {"Min bio"}, "profile_visibility": {"public"}, "default_fave_privacy": {"private"}, } req := postForm("/settings", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "lagret") { t.Error("should show settings saved message") } user, _ := h.deps.Users.GetByUsername("testuser") if user.DisplayName != "Nytt Navn" { t.Errorf("display_name = %q", user.DisplayName) } } func TestSettingsChangePassword(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/settings", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{ "current_password": {"pass123"}, "new_password": {"newpass456"}, "confirm_password": {"newpass456"}, } req := postForm("/settings/password", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "endret") { t.Errorf("should show password changed: %s", rr.Body.String()) } // Verify new password works. _, err := h.deps.Users.Authenticate("testuser", "newpass456") if err != nil { t.Errorf("new password should work: %v", err) } } func TestSettingsChangePasswordWrongCurrent(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") getReq := httptest.NewRequest("GET", "/settings", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{ "current_password": {"wrongpass"}, "new_password": {"newpass456"}, "confirm_password": {"newpass456"}, } req := postForm("/settings/password", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "feil") { t.Error("should show wrong password error") } } // --- Tag autocomplete --- // TestTagSuggestionsNoInlineHandlers is a regression test for the autocomplete click bug. // Tag suggestions must NOT use inline event handlers (onclick, onmousedown) because // they are blocked by CSP (script-src 'self'). Instead, they must use data-tag-name // attributes and delegated event listeners in app.js. func TestTagSuggestionsNoInlineHandlers(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public") h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines"}) req := httptest.NewRequest("GET", "/tags/search?q=go", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("tag search: got %d, want 200", rr.Code) } body := rr.Body.String() // Must NOT use inline handlers — they are blocked by CSP script-src 'self'. if strings.Contains(body, "onclick=") { t.Error("tag suggestions must not use onclick (blocked by CSP)") } if strings.Contains(body, "onmousedown=") { t.Error("tag suggestions must not use onmousedown (blocked by CSP)") } if strings.Contains(body, "ontouchstart=") { t.Error("tag suggestions must not use ontouchstart (blocked by CSP)") } // Must use data-tag-name for delegated event handling in app.js. if !strings.Contains(body, "data-tag-name=") { t.Error("tag suggestions must use data-tag-name attribute for delegated event handling") } // Verify the tag names are in the data attributes. if !strings.Contains(body, `data-tag-name="golang"`) { t.Error("suggestion should have data-tag-name with the tag name") } } // TestTagSuggestionsDisplayName verifies that the "av" display name bug is fixed. // The fave list should show the username as fallback when display_name is empty. func TestDisplayNameFallbackToUsername(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") // Create a user WITHOUT a display name and a public fave. user, _ := h.deps.Users.GetByUsername("testuser") h.deps.Faves.Create(user.ID, "Test fave", "", "", "", "public") // The home page shows "av " — with no display name set, // it should fall back to the username. req := httptest.NewRequest("GET", "/", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() // Should show "av testuser" (username as fallback), not just "av". if !strings.Contains(body, "testuser") { t.Error("home page should show username as fallback when display_name is empty") } } // --- PWA --- func TestManifestJSON(t *testing.T) { _, mux := testServer(t) req := httptest.NewRequest("GET", "/manifest.json", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("manifest: got %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.Contains(ct, "manifest+json") { t.Errorf("content-type = %q, want manifest+json", ct) } var manifest map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &manifest); err != nil { t.Fatalf("parse manifest: %v", err) } if manifest["name"] != "Test" { t.Errorf("name = %v, want Test", manifest["name"]) } // share_target should exist with GET method. st, ok := manifest["share_target"].(map[string]any) if !ok { t.Fatal("manifest missing share_target") } if st["method"] != "GET" { t.Errorf("share_target method = %v, want GET", st["method"]) } } func TestServiceWorkerContent(t *testing.T) { _, mux := testServer(t) req := httptest.NewRequest("GET", "/sw.js", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("sw.js: got %d, want 200", rr.Code) } ct := rr.Header().Get("Content-Type") if !strings.Contains(ct, "javascript") { t.Errorf("content-type = %q, want javascript", ct) } cc := rr.Header().Get("Cache-Control") if cc != "no-cache" { t.Errorf("Cache-Control = %q, want no-cache", cc) } body := rr.Body.String() if strings.Contains(body, "{{BASE_PATH}}") { t.Error("sw.js should have BASE_PATH placeholder replaced") } if !strings.Contains(body, "CACHE_NAME") { t.Error("sw.js should contain service worker code") } } func TestShareRedirectsToFaveNew(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") req := httptest.NewRequest("GET", "/share?url=https://example.com&title=Test+Page", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("share: got %d, want 303", rr.Code) } loc := rr.Header().Get("Location") if !strings.Contains(loc, "/faves/new") { t.Errorf("redirect = %q, should point to /faves/new", loc) } if !strings.Contains(loc, "url=https") { t.Errorf("redirect = %q, should contain url param", loc) } if !strings.Contains(loc, "description=Test") { t.Errorf("redirect = %q, should contain description from title", loc) } } func TestShareTextFieldFallback(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") // Some Android apps put the URL in "text" instead of "url". req := httptest.NewRequest("GET", "/share?text=Check+this+out+https://example.com/article", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) loc := rr.Header().Get("Location") if !strings.Contains(loc, "url=https") { t.Errorf("should extract URL from text field: %q", loc) } } func TestShareRequiresLogin(t *testing.T) { _, mux := testServer(t) req := httptest.NewRequest("GET", "/share?url=https://example.com", nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Errorf("unauthenticated share: got %d, want 303", rr.Code) } loc := rr.Header().Get("Location") if !strings.Contains(loc, "/login") { t.Errorf("should redirect to login: %q", loc) } } func TestFaveNewPreFill(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") req := httptest.NewRequest("GET", "/faves/new?url=https://example.com&description=Shared+Page¬es=Great+article", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("fave new pre-fill: got %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "https://example.com") { t.Error("URL should be pre-filled") } if !strings.Contains(body, "Shared Page") { t.Error("description should be pre-filled") } if !strings.Contains(body, "Great article") { t.Error("notes should be pre-filled") } } // --- Privacy toggle --- func TestTogglePrivacyOwner(t *testing.T) { h, mux := testServer(t) user, _ := h.deps.Users.Create("testuser", "pass123", "user") fave, _ := h.deps.Faves.Create(user.ID, "Toggle me", "", "", "", "public") token, _ := h.deps.Sessions.Create(user.ID) cookie := &http.Cookie{Name: "session", Value: token} getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("toggle privacy: got %d, want 200\nbody: %s", rr.Code, rr.Body.String()) } // Should now be private. updated, _ := h.deps.Faves.GetByID(fave.ID) if updated.Privacy != "private" { t.Errorf("privacy = %q, want private after toggle from public", updated.Privacy) } // Response should contain the toggle partial with "Privat". if !strings.Contains(rr.Body.String(), "Privat") { t.Error("toggle response should show new privacy state") } } func TestTogglePrivacyNotOwner(t *testing.T) { h, mux := testServer(t) userA, _ := h.deps.Users.Create("usera", "pass123", "user") fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public") cookieB := loginUser(t, h, "userb", "pass123", "user") getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) getReq.AddCookie(cookieB) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookieB) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { t.Errorf("toggle by non-owner: got %d, want 403", rr.Code) } } func TestFaveListShowsEditButton(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") user, _ := h.deps.Users.GetByUsername("testuser") h.deps.Faves.Create(user.ID, "Editable fave", "", "", "", "public") req := httptest.NewRequest("GET", "/faves", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) body := rr.Body.String() if !strings.Contains(body, "Rediger") { t.Error("fave list should show edit link") } if !strings.Contains(body, "Slett") { t.Error("fave list should show delete button") } if !strings.Contains(body, "Offentlig") { t.Error("fave list should show privacy toggle") } } // --- Admin: role + delete --- func TestAdminSetRoleSuccess(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") user, _ := h.deps.Users.Create("target", "pass123", "user") getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{"role": {"admin"}} req := postForm("/admin/users/"+faveIDStr(user.ID)+"/role", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "endret til admin") { t.Errorf("should show role changed: %s", rr.Body.String()) } updated, _ := h.deps.Users.GetByID(user.ID) if updated.Role != "admin" { t.Errorf("role = %q, want admin", updated.Role) } } func TestAdminSetRoleSelf(t *testing.T) { h, mux := testServer(t) admin, _ := h.deps.Users.Create("admin", "pass123", "admin") token, _ := h.deps.Sessions.Create(admin.ID) cookie := &http.Cookie{Name: "session", Value: token} getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") form := url.Values{"role": {"user"}} req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/role", csrf, form, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "din egen rolle") { t.Error("should prevent changing own role") } } func TestAdminDeleteUserSuccess(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "admin", "pass123", "admin") user, _ := h.deps.Users.Create("deleteme", "pass123", "user") h.deps.Faves.Create(user.ID, "Will be deleted", "", "", "", "public") getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/admin/users/"+faveIDStr(user.ID)+"/delete", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "permanent slettet") { t.Errorf("should show deleted: %s", rr.Body.String()) } // User should be gone. _, err := h.deps.Users.GetByUsername("deleteme") if err == nil { t.Error("deleted user should not exist") } // Faves should be cascade-deleted. faves, total, _ := h.deps.Faves.ListByUser(user.ID, 10, 0) if total != 0 || len(faves) != 0 { t.Error("faves should be cascade-deleted with user") } } func TestAdminDeleteUserSelf(t *testing.T) { h, mux := testServer(t) admin, _ := h.deps.Users.Create("admin", "pass123", "admin") token, _ := h.deps.Sessions.Create(admin.ID) cookie := &http.Cookie{Name: "session", Value: token} getReq := httptest.NewRequest("GET", "/admin/users", nil) getReq.AddCookie(cookie) getRR := httptest.NewRecorder() mux.ServeHTTP(getRR, getReq) csrf := extractCookie(getRR, "csrf_token") req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/delete", csrf, url.Values{}, cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if !strings.Contains(rr.Body.String(), "din egen konto") { t.Error("should prevent self-deletion") } } // --- Export page --- func TestExportPageRendering(t *testing.T) { h, mux := testServer(t) cookie := loginUser(t, h, "testuser", "pass123", "user") req := httptest.NewRequest("GET", "/export", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("export page: got %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Eksporter") { t.Error("should render export page") } }