favoritter/internal/store/user_test.go

273 lines
6.2 KiB
Go
Raw Permalink Normal View History

feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation Go backend with server-rendered HTML/HTMX frontend, SQLite database, and filesystem image storage. Self-hostable single-binary architecture. Phase 1 — Authentication & project foundation: - Argon2id password hashing with timing-attack prevention - Session management with cookie-based auth and periodic cleanup - Login, signup (open/requests/closed modes), logout, forced password reset - CSRF double-submit cookie pattern with HTMX auto-inclusion - Proxy-aware real IP extraction (WireGuard/Tailscale support) - Configurable base path for subdomain and subpath deployment - Rate limiting on auth endpoints with background cleanup - Security headers (CSP, X-Frame-Options, Referrer-Policy) - Structured logging with slog, graceful shutdown - Pico CSS + HTMX vendored and embedded via go:embed Phase 2 — Faves CRUD with tags and images: - Full CRUD for favorites with ownership checks - Image upload with EXIF stripping, resize to 1920px, UUID filenames - Tag system with HTMX autocomplete (prefix search, popularity-sorted) - Privacy controls (public/private per fave, user-configurable default) - Tag browsing, pagination, batch tag loading (avoids N+1) - OpenGraph meta tags on public fave detail pages Includes code quality pass: extracted shared helpers, fixed signup request persistence bug, plugged rate limiter memory leak, removed dead code, and logged previously-swallowed errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:55:22 +02:00
// SPDX-License-Identifier: AGPL-3.0-or-later
package store
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
"kode.naiv.no/olemd/favoritter/internal/database"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := database.Open(":memory:")
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestCreateAndAuthenticate(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
// Use fast Argon2 parameters for tests.
Argon2Memory = 1024
Argon2Time = 1
defer func() {
Argon2Memory = 65536
Argon2Time = 3
}()
// Create a user.
user, err := users.Create("testuser", "password123", "user")
if err != nil {
t.Fatalf("create user: %v", err)
}
if user.Username != "testuser" {
t.Errorf("username = %q, want %q", user.Username, "testuser")
}
if user.Role != "user" {
t.Errorf("role = %q, want %q", user.Role, "user")
}
// Authenticate with correct password.
authed, err := users.Authenticate("testuser", "password123")
if err != nil {
t.Fatalf("authenticate: %v", err)
}
if authed.ID != user.ID {
t.Errorf("authenticated user ID = %d, want %d", authed.ID, user.ID)
}
// Authenticate with wrong password.
_, err = users.Authenticate("testuser", "wrongpassword")
if err != ErrInvalidCredentials {
t.Errorf("wrong password error = %v, want ErrInvalidCredentials", err)
}
// Authenticate with non-existent user.
_, err = users.Authenticate("nouser", "password123")
if err != ErrInvalidCredentials {
t.Errorf("non-existent user error = %v, want ErrInvalidCredentials", err)
}
}
func TestCreateDuplicate(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() {
Argon2Memory = 65536
Argon2Time = 3
}()
_, err := users.Create("testuser", "password123", "user")
if err != nil {
t.Fatalf("create user: %v", err)
}
_, err = users.Create("testuser", "password456", "user")
if err != ErrUserExists {
t.Errorf("duplicate error = %v, want ErrUserExists", err)
}
// Case-insensitive duplicate.
_, err = users.Create("TestUser", "password456", "user")
if err != ErrUserExists {
t.Errorf("case-insensitive duplicate error = %v, want ErrUserExists", err)
}
}
func TestUpdatePassword(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() {
Argon2Memory = 65536
Argon2Time = 3
}()
user, err := users.CreateWithReset("admin", "temppass", "admin")
if err != nil {
t.Fatalf("create user: %v", err)
}
if !user.MustResetPassword {
t.Error("expected must_reset_password to be true")
}
err = users.UpdatePassword(user.ID, "newpassword123")
if err != nil {
t.Fatalf("update password: %v", err)
}
// Verify old password no longer works.
_, err = users.Authenticate("admin", "temppass")
if err != ErrInvalidCredentials {
t.Error("old password should not work after reset")
}
// Verify new password works and reset flag is cleared.
updated, err := users.Authenticate("admin", "newpassword123")
if err != nil {
t.Fatalf("authenticate with new password: %v", err)
}
if updated.MustResetPassword {
t.Error("must_reset_password should be false after update")
}
}
func TestEnsureAdmin(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() {
Argon2Memory = 65536
Argon2Time = 3
}()
// First call creates the admin.
err := users.EnsureAdmin("admin", "adminpass")
if err != nil {
t.Fatalf("ensure admin: %v", err)
}
admin, err := users.GetByUsername("admin")
if err != nil {
t.Fatalf("get admin: %v", err)
}
if !admin.IsAdmin() {
t.Error("expected admin role")
}
// Second call is a no-op.
err = users.EnsureAdmin("admin", "adminpass")
if err != nil {
t.Fatalf("ensure admin (second call): %v", err)
}
count, _ := users.Count()
if count != 1 {
t.Errorf("user count = %d, want 1", count)
}
}
func TestDisabledUser(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() {
Argon2Memory = 65536
Argon2Time = 3
}()
user, err := users.Create("testuser", "password123", "user")
if err != nil {
t.Fatalf("create user: %v", err)
}
// Disable the user.
err = users.SetDisabled(user.ID, true)
if err != nil {
t.Fatalf("disable user: %v", err)
}
// Authentication should fail.
_, err = users.Authenticate("testuser", "password123")
if err != ErrUserDisabled {
t.Errorf("disabled user error = %v, want ErrUserDisabled", err)
}
}
func TestSetRole(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
if user.Role != "user" {
t.Fatalf("initial role = %q", user.Role)
}
// Promote to admin.
err := users.SetRole(user.ID, "admin")
if err != nil {
t.Fatalf("set role: %v", err)
}
updated, _ := users.GetByID(user.ID)
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
// Invalid role should error.
err = users.SetRole(user.ID, "superadmin")
if err == nil {
t.Error("invalid role should return error")
}
}
func TestDeleteUser(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("deleteme", "password123", "user")
faves.Create(user.ID, "Fave 1", "", "", "", "public")
faves.Create(user.ID, "Fave 2", "", "", "", "public")
err := users.Delete(user.ID)
if err != nil {
t.Fatalf("delete user: %v", err)
}
// User should be gone.
_, err = users.GetByUsername("deleteme")
if err != ErrUserNotFound {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
// Faves should be cascade-deleted.
list, total, _ := faves.ListByUser(user.ID, 10, 0)
if total != 0 || len(list) != 0 {
t.Errorf("faves should be cascade-deleted: total=%d, len=%d", total, len(list))
}
// Deleting non-existent user should return error.
err = users.Delete(99999)
if err != ErrUserNotFound {
t.Errorf("delete nonexistent: %v, want ErrUserNotFound", err)
}
}