578 lines
14 KiB
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
|
|
}
|