Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
465 lines
14 KiB
Go
465 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
|
)
|
|
|
|
type usersResponseTest struct {
|
|
Users []userPublic `json:"users"`
|
|
}
|
|
|
|
func TestUsersGetListsSanitizedFields(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/users", nil), cookie)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
rawBody := recorder.Body.Bytes()
|
|
var payload usersResponseTest
|
|
if err := json.Unmarshal(rawBody, &payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload.Users) != 1 {
|
|
t.Fatalf("expected 1 user, got %d", len(payload.Users))
|
|
}
|
|
|
|
user := payload.Users[0]
|
|
if user.Username != "Admin" {
|
|
t.Fatalf("expected username Admin, got %q", user.Username)
|
|
}
|
|
if !user.IsAdmin {
|
|
t.Fatal("expected is_admin true")
|
|
}
|
|
if user.CanDelete {
|
|
t.Fatal("expected can_delete false for sole admin")
|
|
}
|
|
if user.ID == "" || user.Username == "" {
|
|
t.Fatal("expected id and username")
|
|
}
|
|
|
|
raw := string(rawBody)
|
|
if strings.Contains(raw, "password_hash") || strings.Contains(raw, "totp_secret") {
|
|
t.Fatalf("response leaked secrets: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodDelete,
|
|
"/api/v1/users?id=test-user-id",
|
|
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
var payload apiErrorResponseTest
|
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if payload.Error != "cannot delete the last administrator" {
|
|
t.Fatalf("unexpected error: %q", payload.Error)
|
|
}
|
|
}
|
|
|
|
func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("KeyFromEnv: %v", err)
|
|
}
|
|
store, err := settings.LoadPasswords(configDir, key)
|
|
if err != nil {
|
|
t.Fatalf("LoadPasswords: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
store.Users = append(store.Users,
|
|
settings.UserCredential{
|
|
ID: "operator-id",
|
|
Username: "operator",
|
|
PasswordHash: passwordHash,
|
|
Enabled: true,
|
|
GroupNames: []string{"Operators"},
|
|
CreatedAt: now,
|
|
PasswordChangedAt: now,
|
|
},
|
|
settings.UserCredential{
|
|
ID: "admin-two-id",
|
|
Username: "AdminTwo",
|
|
PasswordHash: passwordHash,
|
|
Enabled: true,
|
|
GroupNames: []string{settings.AdministratorsGroupName},
|
|
CreatedAt: now,
|
|
PasswordChangedAt: now,
|
|
},
|
|
)
|
|
if err := settings.SavePasswords(configDir, store, key); err != nil {
|
|
t.Fatalf("SavePasswords: %v", err)
|
|
}
|
|
|
|
router := NewRouter(configDir)
|
|
reauthBody := `{"current_password":"correct horse battery staple extra"}`
|
|
|
|
deleteOperator := withSession(
|
|
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
|
|
cookie,
|
|
)
|
|
deleteOperator.Header.Set("Content-Type", "application/json")
|
|
operatorRec := httptest.NewRecorder()
|
|
router.ServeHTTP(operatorRec, deleteOperator)
|
|
if operatorRec.Code != http.StatusOK {
|
|
t.Fatalf("delete operator: expected %d, got %d body=%s", http.StatusOK, operatorRec.Code, operatorRec.Body.String())
|
|
}
|
|
|
|
deleteSecondAdmin := withSession(
|
|
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
|
|
cookie,
|
|
)
|
|
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
|
|
adminRec := httptest.NewRecorder()
|
|
router.ServeHTTP(adminRec, deleteSecondAdmin)
|
|
if adminRec.Code != http.StatusOK {
|
|
t.Fatalf("delete second admin: expected %d, got %d body=%s", http.StatusOK, adminRec.Code, adminRec.Body.String())
|
|
}
|
|
|
|
var payload usersResponseTest
|
|
if err := json.NewDecoder(adminRec.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(payload.Users) != 1 {
|
|
t.Fatalf("expected 1 remaining user, got %d", len(payload.Users))
|
|
}
|
|
if payload.Users[0].ID != "test-user-id" {
|
|
t.Fatalf("expected sole remaining admin id, got %q", payload.Users[0].ID)
|
|
}
|
|
if payload.Users[0].CanDelete {
|
|
t.Fatal("expected remaining sole admin can_delete false")
|
|
}
|
|
}
|
|
|
|
func TestUsersDeleteMissingIDFails(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/users", nil), cookie)
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
|
|
}
|
|
}
|
|
|
|
func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodDelete,
|
|
"/api/v1/users?id=missing",
|
|
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsersCreateAndPatch(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("KeyFromEnv: %v", err)
|
|
}
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
payload.Groups = append(payload.Groups, settings.Group{
|
|
Name: "Operators",
|
|
ScopeKind: settings.GroupScopeGroup,
|
|
ScopeName: "Ops",
|
|
Permissions: []string{"nodes.read"},
|
|
})
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
|
|
router := NewRouter(configDir)
|
|
|
|
createReq := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/users",
|
|
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
|
|
),
|
|
cookie,
|
|
)
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createRec := httptest.NewRecorder()
|
|
router.ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusOK {
|
|
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
|
|
}
|
|
|
|
var created usersResponseTest
|
|
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode create: %v", err)
|
|
}
|
|
var aliceID string
|
|
for _, user := range created.Users {
|
|
if user.Username == "alice" {
|
|
aliceID = user.ID
|
|
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
|
|
t.Fatalf("unexpected groups: %#v", user.GroupNames)
|
|
}
|
|
}
|
|
}
|
|
if aliceID == "" {
|
|
t.Fatal("alice not found after create")
|
|
}
|
|
|
|
patchReq := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPatch,
|
|
"/api/v1/users?id="+aliceID,
|
|
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
patchReq.Header.Set("Content-Type", "application/json")
|
|
patchRec := httptest.NewRecorder()
|
|
router.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusOK {
|
|
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
|
|
}
|
|
|
|
store, err := settings.LoadPasswords(configDir, key)
|
|
if err != nil {
|
|
t.Fatalf("LoadPasswords: %v", err)
|
|
}
|
|
for _, user := range store.Users {
|
|
if user.ID != aliceID {
|
|
continue
|
|
}
|
|
if user.TOTPConfirmed || user.TOTPSecret != "" {
|
|
t.Fatal("expected TOTP cleared")
|
|
}
|
|
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
|
|
if err != nil || !ok {
|
|
t.Fatal("expected password updated")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsersWriteRequiresPermission(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("KeyFromEnv: %v", err)
|
|
}
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
// Strip users.manage from Administrators
|
|
for index := range payload.Groups {
|
|
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
|
continue
|
|
}
|
|
filtered := make([]string, 0)
|
|
for _, permission := range payload.Groups[index].Permissions {
|
|
if permission == "users.manage" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, permission)
|
|
}
|
|
payload.Groups[index].Permissions = filtered
|
|
}
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
_ = key
|
|
|
|
router := NewRouter(configDir)
|
|
request := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/users",
|
|
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsersPatchRequiresGraceReauth(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
|
|
key, err := settings.KeyFromEnv()
|
|
if err != nil {
|
|
t.Fatalf("KeyFromEnv: %v", err)
|
|
}
|
|
payload, err := settings.LoadSettings(configDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadSettings: %v", err)
|
|
}
|
|
payload.Security.ReauthSensitiveActions = true
|
|
payload.Security.ReauthGraceMinutes = 0
|
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
|
t.Fatalf("SaveSettings: %v", err)
|
|
}
|
|
|
|
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
|
|
store, err := settings.LoadSessionsOrEmpty(configDir, key)
|
|
if err != nil {
|
|
t.Fatalf("LoadSessions: %v", err)
|
|
}
|
|
for index := range store.Sessions {
|
|
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
|
|
}
|
|
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
|
t.Fatalf("SaveSessions: %v", err)
|
|
}
|
|
|
|
router := NewRouter(configDir)
|
|
patchReq := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPatch,
|
|
"/api/v1/users?id=test-user-id",
|
|
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
patchReq.Header.Set("Content-Type", "application/json")
|
|
patchRec := httptest.NewRecorder()
|
|
router.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
|
|
}
|
|
var errPayload apiErrorResponseTest
|
|
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if errPayload.Error != "reauth_required" {
|
|
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
|
|
}
|
|
|
|
reauthReq := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/api/v1/auth/reauth",
|
|
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
reauthReq.Header.Set("Content-Type", "application/json")
|
|
reauthRec := httptest.NewRecorder()
|
|
router.ServeHTTP(reauthRec, reauthReq)
|
|
if reauthRec.Code != http.StatusOK {
|
|
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
|
|
}
|
|
|
|
// Refresh cookie if rotated
|
|
for _, setCookie := range reauthRec.Result().Cookies() {
|
|
if setCookie.Name == auth.SessionCookieName {
|
|
cookie = setCookie
|
|
}
|
|
}
|
|
|
|
retryReq := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPatch,
|
|
"/api/v1/users?id=test-user-id",
|
|
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
retryReq.Header.Set("Content-Type", "application/json")
|
|
retryRec := httptest.NewRecorder()
|
|
router.ServeHTTP(retryRec, retryReq)
|
|
if retryRec.Code != http.StatusOK {
|
|
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMePasswordChange(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
cookie := seedCompletedSetup(t, configDir)
|
|
router := NewRouter(configDir)
|
|
|
|
request := withSession(
|
|
httptest.NewRequest(
|
|
http.MethodPut,
|
|
"/api/v1/me/password",
|
|
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
|
|
),
|
|
cookie,
|
|
)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
// Session should be invalidated
|
|
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
|
|
meRec := httptest.NewRecorder()
|
|
router.ServeHTTP(meRec, meReq)
|
|
if meRec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected session invalidated, got %d", meRec.Code)
|
|
}
|
|
}
|