Admins can now change user roles and permanently delete user accounts.
- New SetRole store method with validation (user/admin only)
- New Delete store method — cascades via foreign keys to sessions,
faves, and fave_tags
- handleAdminSetRole: change role with self-modification prevention
- handleAdminDeleteUser: permanent deletion with image cleanup from
disk before cascade delete, self-deletion prevention
- admin_users.html: role dropdown with save button per user row,
delete button with hx-confirm for safety
- Routes: POST /admin/users/{id}/role, POST /admin/users/{id}/delete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
273 lines
6.2 KiB
Go
273 lines
6.2 KiB
Go
// 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)
|
|
}
|
|
}
|