NextWks/src/core/admin/admin_test.go

578 lines
14 KiB
Go

package admin
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// setupTestDB creates a temporary SQLite database for testing.
func setupTestDB(t *testing.T) (*UserStore, string, func()) {
t.Helper()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
database, err := initDB(dbPath)
if err != nil {
t.Fatalf("init db: %v", err)
}
store := NewUserStore(database)
cleanup := func() {
database.Close()
}
return store, tmpDir, cleanup
}
// initDB opens a SQLite database and runs migrations.
func initDB(path string) (*sql.DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.Exec("PRAGMA journal_mode=WAL")
db.Exec("PRAGMA foreign_keys=ON")
// Run migrations
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
groups TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// --- UserStore Tests ---
func TestUserStore_List_Empty(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
users, err := store.List()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(users) != 0 {
t.Errorf("expected empty list, got %d users", len(users))
}
}
func TestUserStore_Create_SingleUser(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{
{Username: "jdoe", DisplayName: "John Doe", Email: "john@example.com", Groups: "admins,users"},
},
}
results := store.Create(req)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error != "" {
t.Fatalf("expected no error, got: %s", results[0].Error)
}
if results[0].Username != "jdoe" {
t.Errorf("expected username 'jdoe', got %q", results[0].Username)
}
if results[0].GeneratedPassword == "" {
t.Error("expected generated password to be non-empty")
}
if len(results[0].GeneratedPassword) < 16 {
t.Errorf("expected password >= 16 chars, got %d", len(results[0].GeneratedPassword))
}
}
func TestUserStore_Create_MultipleUsers(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1", DisplayName: "User One"},
{Username: "user2", DisplayName: "User Two"},
{Username: "user3", DisplayName: "User Three"},
},
}
results := store.Create(req)
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
for _, r := range results {
if r.Error != "" {
t.Errorf("unexpected error for %s: %s", r.Username, r.Error)
}
}
users, _ := store.List()
if len(users) != 3 {
t.Errorf("expected 3 users, got %d", len(users))
}
}
func TestUserStore_Create_DuplicateUsername(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req1 := CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John Doe"}},
}
store.Create(req1)
req2 := CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "Jane Doe"}},
}
results := store.Create(req2)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error == "" {
t.Fatal("expected error for duplicate username, got nil")
}
if results[0].Error != "user already exists" {
t.Errorf("expected 'user already exists', got %q", results[0].Error)
}
}
func TestUserStore_Create_EmptyUsername(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
req := CreateUserRequest{
Users: []CreateUserInput{{Username: ""}},
}
results := store.Create(req)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Error != "username is required" {
t.Errorf("expected 'username is required', got %q", results[0].Error)
}
}
func TestUserStore_GetByUsername_Found(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe", DisplayName: "John", Email: "john@test.com"}},
})
user, err := store.GetByUsername("jdoe")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if user == nil {
t.Fatal("expected user to be found")
}
if user.DisplayName != "John" {
t.Errorf("expected display name 'John', got %q", user.DisplayName)
}
if user.Email != "john@test.com" {
t.Errorf("expected email 'john@test.com', got %q", user.Email)
}
}
func TestUserStore_GetByUsername_NotFound(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
user, err := store.GetByUsername("nonexistent")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if user != nil {
t.Fatal("expected nil for nonexistent user")
}
}
func TestUserStore_Delete_Existing(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{{Username: "jdoe"}},
})
if err := store.Delete("jdoe"); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
user, _ := store.GetByUsername("jdoe")
if user != nil {
t.Error("expected user to be deleted")
}
}
func TestUserStore_Delete_NotFound(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
err := store.Delete("nonexistent")
if err == nil {
t.Fatal("expected error for deleting nonexistent user")
}
}
func TestUserStore_Count(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
count, _ := store.Count()
if count != 0 {
t.Errorf("expected count 0, got %d", count)
}
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1"},
{Username: "user2"},
},
})
count, _ = store.Count()
if count != 2 {
t.Errorf("expected count 2, got %d", count)
}
}
func TestUserStore_SyncSnapshot(t *testing.T) {
store, _, cleanup := setupTestDB(t)
defer cleanup()
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "user1", DisplayName: "User One", Email: "u1@test.com", Groups: "admins"},
{Username: "user2", DisplayName: "User Two", Groups: "users,devs"},
},
})
snapshot, err := store.SyncSnapshot()
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(snapshot) != 2 {
t.Fatalf("expected 2 users in snapshot, got %d", len(snapshot))
}
// Check groups parsing
if len(snapshot[0].Groups) != 1 || snapshot[0].Groups[0] != "admins" {
t.Errorf("expected groups ['admins'], got %v", snapshot[0].Groups)
}
if len(snapshot[1].Groups) != 2 {
t.Errorf("expected 2 groups, got %v", snapshot[1].Groups)
}
}
// --- Auth Tests ---
func TestTokenAuthMiddleware_ValidToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_InvalidToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_MissingHeader(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTokenAuthMiddleware_EmptyToken(t *testing.T) {
middleware := TokenAuthMiddleware("test-token")
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}))
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer ")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// --- SyncWriter Tests ---
func TestSyncWriter_Sync(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
sw := NewSyncWriter(usersDBPath, store)
store.Create(CreateUserRequest{
Users: []CreateUserInput{
{Username: "alice", DisplayName: "Alice", Email: "alice@test.com", Groups: "admins"},
{Username: "bob", DisplayName: "Bob", Groups: "users"},
},
})
if err := sw.Sync(); err != nil {
t.Fatalf("sync failed: %v", err)
}
data, err := os.ReadFile(usersDBPath)
if err != nil {
t.Fatalf("read sync file: %v", err)
}
if len(data) == 0 {
t.Fatal("sync file is empty")
}
content := string(data)
if !contains(content, "alice:") {
t.Errorf("expected 'alice:' in sync file")
}
if !contains(content, "bob:") {
t.Errorf("expected 'bob:' in sync file")
}
if !contains(content, "$argon2id$") {
t.Errorf("expected argon2id hash in sync file")
}
}
func TestSyncWriter_Sync_EmptyStore(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "empty_users.yml")
sw := NewSyncWriter(usersDBPath, store)
if err := sw.Sync(); err != nil {
t.Fatalf("sync should succeed with empty store: %v", err)
}
data, _ := os.ReadFile(usersDBPath)
content := string(data)
if !contains(content, "users:") {
t.Errorf("expected 'users:' key even with empty store")
}
}
func TestSyncWriter_Bootstrap_ExistingFile(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
yamlContent := []byte(`
users:
charlie:
displayname: "Charlie"
password: "$argon2id$v=19$m=65536,t=3,p=4$somesalt$somehash"
email: "charlie@test.com"
groups:
- admins
disabled: false
`)
if err := os.WriteFile(usersDBPath, yamlContent, 0644); err != nil {
t.Fatalf("write yaml: %v", err)
}
sw := NewSyncWriter(usersDBPath, store)
imported, err := sw.Bootstrap()
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if imported != 1 {
t.Errorf("expected 1 imported user, got %d", imported)
}
user, _ := store.GetByUsername("charlie")
if user == nil {
t.Fatal("expected charlie to be imported")
}
if user.DisplayName != "Charlie" {
t.Errorf("expected display name 'Charlie', got %q", user.DisplayName)
}
if user.Email != "charlie@test.com" {
t.Errorf("expected email 'charlie@test.com', got %q", user.Email)
}
}
func TestSyncWriter_Bootstrap_NoFile(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "nonexistent.yml")
sw := NewSyncWriter(usersDBPath, store)
imported, err := sw.Bootstrap()
if err != nil {
t.Fatalf("bootstrap should not error on missing file: %v", err)
}
if imported != 0 {
t.Errorf("expected 0 imported, got %d", imported)
}
}
func TestSyncWriter_Bootstrap_Idempotent(t *testing.T) {
store, tmpDir, cleanup := setupTestDB(t)
defer cleanup()
usersDBPath := filepath.Join(tmpDir, "users_database.yml")
yamlContent := []byte("users:\n dave:\n password: \"$argon2id$v=19$m=65536,t=3,p=4$salt$hash\"\n")
os.WriteFile(usersDBPath, yamlContent, 0644)
sw := NewSyncWriter(usersDBPath, store)
imported1, _ := sw.Bootstrap()
imported2, _ := sw.Bootstrap()
if imported1 != 1 {
t.Errorf("expected 1 on first bootstrap, got %d", imported1)
}
if imported2 != 0 {
t.Errorf("expected 0 on second bootstrap (idempotent), got %d", imported2)
}
}
// --- Helper Tests ---
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
input string
delim string
expect []string
}{
{"", ",", nil},
{"a", ",", []string{"a"}},
{"a,b,c", ",", []string{"a", "b", "c"}},
{" a , b , c ", ",", []string{"a", "b", "c"}},
{"admins,users,devs", ",", []string{"admins", "users", "devs"}},
}
for _, tt := range tests {
result := splitAndTrim(tt.input, tt.delim)
if len(result) != len(tt.expect) {
t.Errorf("splitAndTrim(%q) = %v, want %v", tt.input, result, tt.expect)
continue
}
for i := range result {
if result[i] != tt.expect[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, result[i], tt.expect[i])
}
}
}
}
func TestGeneratePassword(t *testing.T) {
pwd, err := generatePassword(20)
if err != nil {
t.Fatalf("generate password: %v", err)
}
if len(pwd) != 20 {
t.Errorf("expected length 20, got %d", len(pwd))
}
pwd2, _ := generatePassword(20)
if pwd == pwd2 {
t.Error("expected different passwords")
}
}
func TestHashPassword(t *testing.T) {
hash := hashPassword("test-password")
if !contains(hash, "$argon2id$") {
t.Errorf("expected argon2id prefix, got %q", hash)
}
if len(hash) < 60 {
t.Errorf("expected reasonably long hash, got %d chars", len(hash))
}
}
// contains checks if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchSubstring(s, substr)
}
func searchSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if s[i+j] != substr[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}