Fave cards in the list and profile views now show edit, delete, and
privacy toggle buttons directly — no need to open the detail page first.
- New POST /faves/{id}/privacy route with HTMX privacy toggle partial
- New UpdatePrivacy store method for single-column update
- fave_list.html: edit link, HTMX delete, privacy toggle on every card
- profile.html: edit/delete for owner's own cards
- privacy_toggle.html: new HTMX partial that swaps inline on toggle
- CSS: compact .fave-card-actions styles
The existing handleFaveDelete already returns empty 200 for HTMX
requests, so hx-target="closest article" hx-swap="outerHTML" removes
the card from DOM seamlessly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1493 lines
42 KiB
Go
1493 lines
42 KiB
Go
// 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 <display_name>" — 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")
|
|
}
|
|
}
|