test: add comprehensive test suite (44 tests across 3 packages)
Store tests (21 tests):
- Session: create, validate, delete, delete-all, expiry
- Signup requests: create, duplicate, list pending, approve
(creates user with must-reset), reject, double-approve/reject
- Existing: user CRUD, auth, fave CRUD, tags, pagination
Middleware tests (9 tests):
- Real IP extraction from trusted/untrusted proxies
- Base path stripping (with prefix, empty prefix)
- Rate limiter (per-IP, exhaustion, different IPs)
- Panic recovery (returns 500)
- Security headers (CSP, X-Frame-Options, etc.)
- RequireLogin redirect
- MustResetPasswordGuard (static path passthrough)
Handler integration tests (14 tests):
- Health endpoint
- Login page rendering, successful login, wrong password
- Fave list requires auth, works when authenticated
- Private fave hidden from other users, visible to owner
- Admin panel requires admin role, works for admin
- Tag search endpoint
- Global Atom feed
- Public profile with display name
- Limited profile hides bio
Also fixes template bugs: profile.html and fave_detail.html used
$.IsOwner which fails inside {{with}} blocks ($ = root PageData,
not .Data map). Fixed with $d variable capture pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa5ab6b415
commit
3a3b526a95
6 changed files with 866 additions and 15 deletions
384
internal/handler/handler_test.go
Normal file
384
internal/handler/handler_test.go
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"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/render"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testServer creates a fully wired test server with an in-memory database.
|
||||||
|
func testServer(t *testing.T) (*Handler, *http.ServeMux) {
|
||||||
|
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() })
|
||||||
|
|
||||||
|
// Use fast Argon2 params for tests.
|
||||||
|
store.Argon2Memory = 1024
|
||||||
|
store.Argon2Time = 1
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
DBPath: ":memory:",
|
||||||
|
Listen: ":0",
|
||||||
|
UploadDir: t.TempDir(),
|
||||||
|
SiteName: "Test",
|
||||||
|
DevMode: false, // Use embedded templates in tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
users := store.NewUserStore(db)
|
||||||
|
sessions := store.NewSessionStore(db)
|
||||||
|
settings := store.NewSettingsStore(db)
|
||||||
|
faves := store.NewFaveStore(db)
|
||||||
|
tags := store.NewTagStore(db)
|
||||||
|
signupRequests := store.NewSignupRequestStore(db)
|
||||||
|
|
||||||
|
renderer, err := render.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := New(Deps{
|
||||||
|
Config: cfg,
|
||||||
|
Users: users,
|
||||||
|
Sessions: sessions,
|
||||||
|
Settings: settings,
|
||||||
|
Faves: faves,
|
||||||
|
Tags: tags,
|
||||||
|
SignupRequests: signupRequests,
|
||||||
|
Renderer: renderer,
|
||||||
|
})
|
||||||
|
|
||||||
|
mux := h.Routes()
|
||||||
|
|
||||||
|
// Wrap with SessionLoader so authenticated tests work.
|
||||||
|
chain := middleware.SessionLoader(sessions, users)(mux)
|
||||||
|
wrappedMux := http.NewServeMux()
|
||||||
|
wrappedMux.Handle("/", chain)
|
||||||
|
|
||||||
|
return h, wrappedMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginUser creates a user and returns a session cookie for authenticated requests.
|
||||||
|
func loginUser(t *testing.T, h *Handler, username, password, role string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
user, err := h.deps.Users.Create(username, password, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
token, err := h.deps.Sessions.Create(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: "session", Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthEndpoint(t *testing.T) {
|
||||||
|
_, mux := testServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("health: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), `"status":"ok"`) {
|
||||||
|
t.Errorf("health body = %q", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginPageRendering(t *testing.T) {
|
||||||
|
_, mux := testServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/login", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("login page: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Logg inn") {
|
||||||
|
t.Error("login page should contain 'Logg inn'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "csrf_token") {
|
||||||
|
t.Error("login page should contain CSRF token field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccess(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
// Create a user.
|
||||||
|
h.deps.Users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
|
// Get CSRF token from login page.
|
||||||
|
getReq := httptest.NewRequest("GET", "/login", nil)
|
||||||
|
getRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(getRR, getReq)
|
||||||
|
csrfCookie := extractCookie(getRR, "csrf_token")
|
||||||
|
|
||||||
|
// POST login.
|
||||||
|
form := url.Values{
|
||||||
|
"username": {"testuser"},
|
||||||
|
"password": {"password123"},
|
||||||
|
"csrf_token": {csrfCookie},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfCookie})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("login POST: got %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have a session cookie.
|
||||||
|
sessionCookie := extractCookie(rr, "session")
|
||||||
|
if sessionCookie == "" {
|
||||||
|
t.Error("login should set session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWrongPassword(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
h.deps.Users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest("GET", "/login", nil)
|
||||||
|
getRR := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(getRR, getReq)
|
||||||
|
csrfCookie := extractCookie(getRR, "csrf_token")
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"username": {"testuser"},
|
||||||
|
"password": {"wrongpassword"},
|
||||||
|
"csrf_token": {csrfCookie},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfCookie})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("wrong password: got %d, want 200 (re-render with flash)", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Feil brukernavn eller passord") {
|
||||||
|
t.Error("should show error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaveListRequiresLogin(t *testing.T) {
|
||||||
|
_, mux := testServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("unauthenticated /faves: got %d, want 303 redirect", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaveListAuthenticated(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("authenticated /faves: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Mine favoritter") {
|
||||||
|
t.Error("fave list page should contain 'Mine favoritter'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateFaveHiddenFromOthers(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
// User A creates a private fave.
|
||||||
|
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "private")
|
||||||
|
|
||||||
|
// User B tries to view it.
|
||||||
|
cookieB := loginUser(t, h, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/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 TestPrivateFaveVisibleToOwner(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private")
|
||||||
|
|
||||||
|
tokenA, _ := h.deps.Sessions.Create(userA.ID)
|
||||||
|
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/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 TestAdminRequiresAdminRole(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
cookie := loginUser(t, h, "regularuser", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("non-admin accessing /admin: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminDashboardForAdmin(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
cookie := loginUser(t, h, "admin", "pass123", "admin")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin dashboard: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Administrasjon") {
|
||||||
|
t.Error("admin dashboard should contain 'Administrasjon'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagSearchEndpoint(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
// Create some tags via faves.
|
||||||
|
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
|
||||||
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"music", "movies", "manga"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tags/search?q=mu", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("tag search: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "music") {
|
||||||
|
t.Error("tag search for 'mu' should include 'music'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGlobal(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
||||||
|
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/feed.xml", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("global feed: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
ct := rr.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "atom+xml") {
|
||||||
|
t.Errorf("content-type = %q, want atom+xml", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Public fave") {
|
||||||
|
t.Error("feed should contain fave title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicProfile(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
||||||
|
h.deps.Users.UpdateProfile(user.ID, "Test User", "Hello world", "public", "public")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/u/testuser", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("public profile: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Test User") {
|
||||||
|
t.Error("profile should show display name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedProfileHidesBio(t *testing.T) {
|
||||||
|
h, mux := testServer(t)
|
||||||
|
|
||||||
|
user, _ := h.deps.Users.Create("private", "pass123", "user")
|
||||||
|
h.deps.Users.UpdateProfile(user.ID, "Hidden User", "Secret bio", "limited", "public")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/u/private", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("limited profile: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if strings.Contains(body, "Secret bio") {
|
||||||
|
t.Error("limited profile should not show bio")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "begrenset synlighet") {
|
||||||
|
t.Error("limited profile should show visibility notice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func faveIDStr(n int64) string {
|
||||||
|
return strconv.FormatInt(n, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCookie gets a cookie value from a response recorder.
|
||||||
|
func extractCookie(rr *httptest.ResponseRecorder, name string) string {
|
||||||
|
for _, c := range rr.Result().Cookies() {
|
||||||
|
if c.Name == name {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
211
internal/middleware/middleware_test.go
Normal file
211
internal/middleware/middleware_test.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealIPFromTrustedProxy(t *testing.T) {
|
||||||
|
_, tailscale, _ := net.ParseCIDR("100.64.0.0/10")
|
||||||
|
trusted := []*net.IPNet{tailscale}
|
||||||
|
|
||||||
|
handler := RealIP(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := RealIPFromContext(r.Context())
|
||||||
|
w.Write([]byte(ip))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Request from trusted proxy with X-Forwarded-For.
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.RemoteAddr = "100.64.1.1:12345"
|
||||||
|
req.Header.Set("X-Forwarded-For", "203.0.113.50, 100.64.1.1")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Body.String() != "203.0.113.50" {
|
||||||
|
t.Errorf("real IP = %q, want %q", rr.Body.String(), "203.0.113.50")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealIPFromUntrustedProxy(t *testing.T) {
|
||||||
|
_, localhost, _ := net.ParseCIDR("127.0.0.1/32")
|
||||||
|
trusted := []*net.IPNet{localhost}
|
||||||
|
|
||||||
|
handler := RealIP(trusted)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := RealIPFromContext(r.Context())
|
||||||
|
w.Write([]byte(ip))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Request from untrusted IP — X-Forwarded-For should be ignored.
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.100:12345"
|
||||||
|
req.Header.Set("X-Forwarded-For", "spoofed-ip")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Body.String() != "192.168.1.100" {
|
||||||
|
t.Errorf("real IP = %q, want %q (should ignore XFF from untrusted)", rr.Body.String(), "192.168.1.100")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasePathStripping(t *testing.T) {
|
||||||
|
handler := BasePath("/faves")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(r.URL.Path))
|
||||||
|
}))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/faves/", "/"},
|
||||||
|
{"/faves/login", "/login"},
|
||||||
|
{"/faves/u/test", "/u/test"},
|
||||||
|
{"/other", "/other"}, // No prefix match — passed through unchanged.
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
req := httptest.NewRequest("GET", tt.path, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Body.String() != tt.want {
|
||||||
|
t.Errorf("BasePath(%q) = %q, want %q", tt.path, rr.Body.String(), tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasePathEmpty(t *testing.T) {
|
||||||
|
// Empty base path should be a no-op.
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(r.URL.Path))
|
||||||
|
})
|
||||||
|
handler := BasePath("")(inner)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/login", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Body.String() != "/login" {
|
||||||
|
t.Errorf("empty base path: got %q, want /login", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter(t *testing.T) {
|
||||||
|
rl := NewRateLimiter(3)
|
||||||
|
handler := rl.Limit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
req := httptest.NewRequest("POST", "/login", nil)
|
||||||
|
// RealIP middleware hasn't run, so RealIPFromContext returns "".
|
||||||
|
// The rate limiter falls back to RemoteAddr.
|
||||||
|
req.RemoteAddr = "192.168.1.1:1234"
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("request %d: got %d, want 200", i+1, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request should be rate-limited.
|
||||||
|
req := httptest.NewRequest("POST", "/login", nil)
|
||||||
|
req.RemoteAddr = "192.168.1.1:1234"
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("rate-limited request: got %d, want 429", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different IP should not be rate-limited.
|
||||||
|
req = httptest.NewRequest("POST", "/login", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("different IP: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecovery(t *testing.T) {
|
||||||
|
handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("test panic")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("panic recovery: got %d, want 500", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
|
handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
}
|
||||||
|
for key, want := range headers {
|
||||||
|
got := rr.Header().Get(key)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
csp := rr.Header().Get("Content-Security-Policy")
|
||||||
|
if csp == "" {
|
||||||
|
t.Error("Content-Security-Policy header missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireLoginRedirects(t *testing.T) {
|
||||||
|
handler := RequireLogin("/faves")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// No user in context — should redirect.
|
||||||
|
req := httptest.NewRequest("GET", "/settings", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("no user: got %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
loc := rr.Header().Get("Location")
|
||||||
|
if loc != "/faves/login" {
|
||||||
|
t.Errorf("redirect location = %q, want /faves/login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustResetPasswordGuard(t *testing.T) {
|
||||||
|
handler := MustResetPasswordGuard("")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// No user — should pass through.
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("no user: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static paths should always pass through even with must-reset user.
|
||||||
|
req = httptest.NewRequest("GET", "/static/css/style.css", nil)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("static path: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
internal/store/session_test.go
Normal file
119
internal/store/session_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionCreateAndValidate(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
sessions := NewSessionStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
|
token, err := sessions.Create(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
if len(token) != 64 { // 32 bytes hex-encoded
|
||||||
|
t.Errorf("token length = %d, want 64", len(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sessions.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate session: %v", err)
|
||||||
|
}
|
||||||
|
if session.UserID != user.ID {
|
||||||
|
t.Errorf("session user ID = %d, want %d", session.UserID, user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionValidateInvalidToken(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
sessions := NewSessionStore(db)
|
||||||
|
|
||||||
|
_, err := sessions.Validate("nonexistent-token")
|
||||||
|
if err != ErrSessionNotFound {
|
||||||
|
t.Errorf("err = %v, want ErrSessionNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDelete(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
sessions := NewSessionStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
|
||||||
|
err := sessions.Delete(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sessions.Validate(token)
|
||||||
|
if err != ErrSessionNotFound {
|
||||||
|
t.Errorf("after delete: err = %v, want ErrSessionNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDeleteAllForUser(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
sessions := NewSessionStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
token1, _ := sessions.Create(user.ID)
|
||||||
|
token2, _ := sessions.Create(user.ID)
|
||||||
|
|
||||||
|
err := sessions.DeleteAllForUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete all: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sessions.Validate(token1)
|
||||||
|
if err != ErrSessionNotFound {
|
||||||
|
t.Error("token1 should be deleted")
|
||||||
|
}
|
||||||
|
_, err = sessions.Validate(token2)
|
||||||
|
if err != ErrSessionNotFound {
|
||||||
|
t.Error("token2 should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionExpiry(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
sessions := NewSessionStore(db)
|
||||||
|
sessions.SetLifetime(1 * time.Millisecond)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
|
||||||
|
// Wait for expiry.
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err := sessions.Validate(token)
|
||||||
|
if err != ErrSessionNotFound {
|
||||||
|
t.Errorf("expired session: err = %v, want ErrSessionNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
internal/store/signup_request_test.go
Normal file
135
internal/store/signup_request_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignupRequestCreateAndList(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
requests := NewSignupRequestStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
err := requests.Create("newuser", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate should fail.
|
||||||
|
err = requests.Create("newuser", "password456")
|
||||||
|
if err != ErrSignupRequestExists {
|
||||||
|
t.Errorf("duplicate: err = %v, want ErrSignupRequestExists", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := requests.ListPending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("pending count = %d, want 1", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].Username != "newuser" {
|
||||||
|
t.Errorf("username = %q, want %q", pending[0].Username, "newuser")
|
||||||
|
}
|
||||||
|
if pending[0].Status != "pending" {
|
||||||
|
t.Errorf("status = %q, want pending", pending[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := requests.PendingCount()
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("pending count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignupRequestApprove(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
requests := NewSignupRequestStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
// Create an admin to act as reviewer.
|
||||||
|
admin, _ := users.Create("admin", "adminpass", "admin")
|
||||||
|
|
||||||
|
// Create a signup request.
|
||||||
|
requests.Create("newuser", "password123")
|
||||||
|
pending, _ := requests.ListPending()
|
||||||
|
requestID := pending[0].ID
|
||||||
|
|
||||||
|
// Approve it.
|
||||||
|
err := requests.Approve(requestID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("approve: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user should now exist with must_reset_password=1.
|
||||||
|
user, err := users.GetByUsername("newuser")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get approved user: %v", err)
|
||||||
|
}
|
||||||
|
if !user.MustResetPassword {
|
||||||
|
t.Error("approved user should have must_reset_password=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request should no longer be pending.
|
||||||
|
count, _ := requests.PendingCount()
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("pending count after approve = %d, want 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The approved request should have the correct status.
|
||||||
|
sr, _ := requests.GetByID(requestID)
|
||||||
|
if sr.Status != "approved" {
|
||||||
|
t.Errorf("status = %q, want approved", sr.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-approve should fail.
|
||||||
|
err = requests.Approve(requestID, admin.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("double approve should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignupRequestReject(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
users := NewUserStore(db)
|
||||||
|
requests := NewSignupRequestStore(db)
|
||||||
|
|
||||||
|
Argon2Memory = 1024
|
||||||
|
Argon2Time = 1
|
||||||
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
|
admin, _ := users.Create("admin", "adminpass", "admin")
|
||||||
|
requests.Create("rejectme", "password123")
|
||||||
|
pending, _ := requests.ListPending()
|
||||||
|
requestID := pending[0].ID
|
||||||
|
|
||||||
|
err := requests.Reject(requestID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not be in pending list.
|
||||||
|
count, _ := requests.PendingCount()
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("pending count after reject = %d, want 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User should NOT have been created.
|
||||||
|
_, err = users.GetByUsername("rejectme")
|
||||||
|
if err != ErrUserNotFound {
|
||||||
|
t.Errorf("rejected user should not exist: err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-reject should fail.
|
||||||
|
err = requests.Reject(requestID, admin.ID)
|
||||||
|
if err != ErrSignupRequestNotFound {
|
||||||
|
t.Errorf("double reject: err = %v, want ErrSignupRequestNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
{{$d := .}}
|
||||||
<article>
|
<article>
|
||||||
{{with .Fave}}
|
{{with .Fave}}
|
||||||
{{if .ImagePath}}
|
{{if .ImagePath}}
|
||||||
|
|
@ -54,7 +55,7 @@
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<small>Lagt til {{.CreatedAt.Format "02.01.2006"}}</small>
|
<small>Lagt til {{.CreatedAt.Format "02.01.2006"}}</small>
|
||||||
{{if $.IsOwner}}
|
{{if $d.IsOwner}}
|
||||||
<nav class="fave-actions">
|
<nav class="fave-actions">
|
||||||
<a href="{{basePath}}/faves/{{.ID}}/edit" role="button" class="outline">Rediger</a>
|
<a href="{{basePath}}/faves/{{.ID}}/edit" role="button" class="outline">Rediger</a>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
{{with .Data}}{{with .ProfileUser}}
|
{{with .Data}}{{$d := .}}{{with .ProfileUser}}
|
||||||
{{if eq .ProfileVisibility "public"}}
|
{{if eq .ProfileVisibility "public"}}
|
||||||
<meta property="og:title" content="{{.DisplayNameOrUsername}} sine favoritter">
|
<meta property="og:title" content="{{.DisplayNameOrUsername}} sine favoritter">
|
||||||
<meta property="og:type" content="profile">
|
<meta property="og:type" content="profile">
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
{{$d := .}}
|
||||||
{{with .ProfileUser}}
|
{{with .ProfileUser}}
|
||||||
<section class="profile-header">
|
<section class="profile-header">
|
||||||
{{if .AvatarPath}}
|
{{if .AvatarPath}}
|
||||||
|
|
@ -31,14 +32,14 @@
|
||||||
</hgroup>
|
</hgroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{if not $.IsLimited}}
|
{{if not $d.IsLimited}}
|
||||||
{{if .Bio}}
|
{{if .Bio}}
|
||||||
<p>{{.Bio}}</p>
|
<p>{{.Bio}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<p><small>Medlem siden {{.CreatedAt.Format "02.01.2006"}}</small></p>
|
<p><small>Medlem siden {{.CreatedAt.Format "02.01.2006"}}</small></p>
|
||||||
|
|
||||||
{{if $.IsOwner}}
|
{{if $d.IsOwner}}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{basePath}}/settings" role="button" class="outline">Rediger profil</a>
|
<a href="{{basePath}}/settings" role="button" class="outline">Rediger profil</a>
|
||||||
<a href="{{basePath}}/faves/new" role="button">+ Ny favoritt</a>
|
<a href="{{basePath}}/faves/new" role="button">+ Ny favoritt</a>
|
||||||
|
|
@ -46,13 +47,13 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<h2>
|
<h2>
|
||||||
{{if $.IsOwner}}Favoritter{{else}}Offentlige favoritter{{end}}
|
{{if $d.IsOwner}}Favoritter{{else}}Offentlige favoritter{{end}}
|
||||||
<small>({{$.Total}})</small>
|
<small>({{$d.Total}})</small>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{{if $.Faves}}
|
{{if $d.Faves}}
|
||||||
<div class="fave-grid" role="list">
|
<div class="fave-grid" role="list">
|
||||||
{{range $.Faves}}
|
{{range $d.Faves}}
|
||||||
<article class="fave-card" role="listitem">
|
<article class="fave-card" role="listitem">
|
||||||
{{if .ImagePath}}
|
{{if .ImagePath}}
|
||||||
<img src="{{basePath}}/uploads/{{.ImagePath}}"
|
<img src="{{basePath}}/uploads/{{.ImagePath}}"
|
||||||
|
|
@ -78,21 +79,21 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if gt $.TotalPages 1}}
|
{{if gt $d.TotalPages 1}}
|
||||||
<nav aria-label="Sidenavigasjon">
|
<nav aria-label="Sidenavigasjon">
|
||||||
<ul>
|
<ul>
|
||||||
{{if gt $.Page 1}}
|
{{if gt $d.Page 1}}
|
||||||
<li><a href="{{basePath}}/u/{{.Username}}?page={{subtract $.Page 1}}">← Forrige</a></li>
|
<li><a href="{{basePath}}/u/{{.Username}}?page={{subtract $d.Page 1}}">← Forrige</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
<li>Side {{$.Page}} av {{$.TotalPages}}</li>
|
<li>Side {{$d.Page}} av {{$d.TotalPages}}</li>
|
||||||
{{if lt $.Page $.TotalPages}}
|
{{if lt $d.Page $d.TotalPages}}
|
||||||
<li><a href="{{basePath}}/u/{{.Username}}?page={{add $.Page 1}}">Neste →</a></li>
|
<li><a href="{{basePath}}/u/{{.Username}}?page={{add $d.Page 1}}">Neste →</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if $.IsOwner}}
|
{{if $d.IsOwner}}
|
||||||
<p>Du har ingen favoritter ennå. <a href="{{basePath}}/faves/new">Legg til din første!</a></p>
|
<p>Du har ingen favoritter ennå. <a href="{{basePath}}/faves/new">Legg til din første!</a></p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>Ingen offentlige favoritter ennå.</p>
|
<p>Ingen offentlige favoritter ennå.</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue