599 lines
18 KiB
Go
599 lines
18 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crypto-miner-server/internal/builder"
|
|
"crypto-miner-server/internal/db"
|
|
"crypto-miner-server/internal/models"
|
|
"crypto-miner-server/internal/pool"
|
|
)
|
|
|
|
const routerAuthCacheTTL = 5 * time.Minute
|
|
|
|
func resetAuthState(t *testing.T) {
|
|
t.Helper()
|
|
authSessionCacheMu.Lock()
|
|
authSessionCache = map[string]time.Time{}
|
|
authSessionCacheMu.Unlock()
|
|
usersMu.Lock()
|
|
authUsers = map[string]string{}
|
|
usersFilePath = ""
|
|
usersMu.Unlock()
|
|
authLoadMu.Lock()
|
|
authLoadedDataDir = ""
|
|
authLoadMu.Unlock()
|
|
SetAgentPathSecret("")
|
|
SetRotateSecretFn(nil)
|
|
t.Cleanup(resetAuthGlobals)
|
|
}
|
|
|
|
func resetAuthGlobals() {
|
|
authSessionCacheMu.Lock()
|
|
authSessionCache = map[string]time.Time{}
|
|
authSessionCacheMu.Unlock()
|
|
SetAgentPathSecret("")
|
|
SetRotateSecretFn(nil)
|
|
}
|
|
|
|
func TestRouterConstants(t *testing.T) {
|
|
if authCacheTTL != routerAuthCacheTTL {
|
|
t.Fatalf("authCacheTTL = %v, want %v", authCacheTTL, routerAuthCacheTTL)
|
|
}
|
|
}
|
|
|
|
func TestAuthCacheKeyDeterministic(t *testing.T) {
|
|
k1 := authCacheKey("user", "pass")
|
|
k2 := authCacheKey("user", "pass")
|
|
if k1 != k2 || k1 == "" {
|
|
t.Fatalf("cache key not stable: %q %q", k1, k2)
|
|
}
|
|
if authCacheKey("user", "other") == k1 {
|
|
t.Fatal("different passwords should produce different cache keys")
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareOptionsPassthrough(t *testing.T) {
|
|
resetAuthState(t)
|
|
called := false
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/config", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if !called || rec.Code != http.StatusNoContent {
|
|
t.Fatalf("OPTIONS should bypass auth: called=%v status=%d", called, rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareHealthPublic(t *testing.T) {
|
|
resetAuthState(t)
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("health should be public, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestBasicAuthMiddlewareBuildDownloadRequiresAuth verifies that build download
|
|
// routes are no longer publicly accessible — they require fleet secret or Basic Auth.
|
|
func TestBasicAuthMiddlewareBuildDownloadRequiresAuth(t *testing.T) {
|
|
resetAuthState(t)
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
paths := []string{
|
|
"/api/v1/builds/abc/download",
|
|
"/api/v1/builds/abc/artifact/worker.exe",
|
|
}
|
|
for _, p := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s without auth should be 401, got %d", p, rec.Code)
|
|
}
|
|
}
|
|
|
|
// Fleet secret should grant access.
|
|
SetAgentPathSecret("test-secret-abc")
|
|
t.Cleanup(func() { SetAgentPathSecret("") })
|
|
for _, p := range paths {
|
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
|
req.Header.Set("X-Fleet-Secret", "test-secret-abc")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s with fleet secret should be 200, got %d", p, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareDropperPublic(t *testing.T) {
|
|
resetAuthState(t)
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
for _, path := range []string{"/get", "/install.sh", "/install.ps1"} {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s should be public, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareMissingCredentials(t *testing.T) {
|
|
resetAuthState(t)
|
|
usersMu.Lock()
|
|
authUsers["admin"] = "secret"
|
|
usersMu.Unlock()
|
|
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler should not run without auth")
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
|
|
t.Fatal("expected WWW-Authenticate header")
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareWrongPassword(t *testing.T) {
|
|
resetAuthState(t)
|
|
hashed, err := hashPassword("correct")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usersMu.Lock()
|
|
authUsers["admin"] = hashed
|
|
usersMu.Unlock()
|
|
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler should not run with bad password")
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
req.SetBasicAuth("admin", "wrong")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rec.Code)
|
|
}
|
|
if strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
|
|
t.Fatal("SPA requests with Authorization must not get WWW-Authenticate")
|
|
}
|
|
}
|
|
|
|
func TestIsSPAAuthRequest(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
if isSPAAuthRequest(req) {
|
|
t.Fatal("bare request should not be SPA")
|
|
}
|
|
req.Header.Set("X-AetherForge-Client", "dashboard")
|
|
if !isSPAAuthRequest(req) {
|
|
t.Fatal("client header should mark SPA request")
|
|
}
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
if !isSPAAuthRequest(req) {
|
|
t.Fatal("Authorization header should mark SPA request")
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareSPANoWWWAuthenticate(t *testing.T) {
|
|
resetAuthState(t)
|
|
usersMu.Lock()
|
|
authUsers["admin"] = "secret"
|
|
usersMu.Unlock()
|
|
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("handler should not run without auth")
|
|
}))
|
|
|
|
for _, setup := range []func(*http.Request){
|
|
func(r *http.Request) { r.Header.Set("X-AetherForge-Client", "dashboard") },
|
|
func(r *http.Request) { r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") },
|
|
} {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
setup(req)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("WWW-Authenticate") != "" {
|
|
t.Fatal("SPA-marked 401 must not include WWW-Authenticate")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareValidCredentials(t *testing.T) {
|
|
resetAuthState(t)
|
|
hashed, err := hashPassword("correct")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usersMu.Lock()
|
|
authUsers["admin"] = hashed
|
|
usersMu.Unlock()
|
|
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
|
req.SetBasicAuth("admin", "correct")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if !authCacheHit("admin", "correct") {
|
|
t.Fatal("successful auth should populate cache")
|
|
}
|
|
}
|
|
|
|
func TestBasicAuthMiddlewareAgentPathFleetSecret(t *testing.T) {
|
|
resetAuthState(t)
|
|
SetAgentPathSecret("fleet-secret-123")
|
|
|
|
h := basicAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("missing fleet secret should be 403, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/agent/decide", nil)
|
|
req.Header.Set("X-Fleet-Secret", "fleet-secret-123")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("valid fleet secret should pass, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRouterPostUsersValidation(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader([]byte(`{}`)))
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("empty payload should be 400, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouterPostUsersConflict(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
body, _ := json.Marshal(map[string]string{"username": testAuthUser, "password": "otherpass"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("existing username should 409, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouterPostWSTicket(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil)
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["ticket"] == nil || body["ticket"] == "" {
|
|
t.Fatalf("expected ticket in response: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestRouterPostUsersSuccess(t *testing.T) {
|
|
router, _, _, dataDir := newTestRouter(t)
|
|
|
|
body, _ := json.Marshal(map[string]string{"username": "newop", "password": "newpass"})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/users", bytes.NewReader(body))
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
usersPath := filepath.Join(dataDir, "users.json")
|
|
data, err := os.ReadFile(usersPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var users map[string]string
|
|
if err := json.Unmarshal(data, &users); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !checkPassword(users["newop"], "newpass") {
|
|
t.Fatal("new user password should be bcrypt stored and verifiable")
|
|
}
|
|
}
|
|
|
|
func TestRouterRotateSecretNotConfigured(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouterRotateSecretSuccess(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
SetRotateSecretFn(func() (string, error) {
|
|
return "new-secret-token-xyz", nil
|
|
})
|
|
t.Cleanup(func() { SetRotateSecretFn(nil) })
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/server/rotate-secret", nil)
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["ok"] != true {
|
|
t.Fatalf("unexpected body: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestRouterBuilderCancelNotFound(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/builder/cancel/missing-token", nil)
|
|
req.SetBasicAuth(testAuthUser, testAuthPass)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestRouterBuildDownloadAuth verifies that build download routes require either
|
|
// the fleet secret (X-Fleet-Secret header) or Basic Auth — they are no longer
|
|
// publicly accessible without credentials.
|
|
func TestRouterBuildDownloadAuth(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
seedTestUsers(t, dataDir)
|
|
database, err := db.New(dataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
|
|
buildID := "dl-build"
|
|
buildDir := filepath.Join(dataDir, "builds", buildID)
|
|
if err := os.MkdirAll(buildDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
binPath := filepath.Join(buildDir, "agent.exe")
|
|
if err := os.WriteFile(binPath, []byte("fake-binary"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.InsertBuild(&models.BuildRecord{
|
|
ID: buildID, WorkerName: "w", ServerURL: "http://x", Wallet: "48x",
|
|
FilePath: binPath, FileName: "agent.exe", Platform: "windows",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const testSecret = "test-fleet-secret-12345"
|
|
SetAgentPathSecret(testSecret)
|
|
t.Cleanup(func() { SetAgentPathSecret("") })
|
|
|
|
wsHub := NewWSHub(database)
|
|
cfg := &mockConfigProvider{}
|
|
configHandler := NewConfigHandler(cfg)
|
|
aiHandler := NewAIHandler(database)
|
|
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
|
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
|
|
|
dlURL := "/api/v1/builds/" + buildID + "/download"
|
|
|
|
// 1. Unauthenticated → 401.
|
|
req := httptest.NewRequest(http.MethodGet, dlURL, nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated download should be 401, got %d", rec.Code)
|
|
}
|
|
|
|
// 2. Valid fleet secret → 200.
|
|
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
|
req.Header.Set("X-Fleet-Secret", testSecret)
|
|
rec = httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("fleet-secret download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// 3. Basic Auth → 200.
|
|
req = httptest.NewRequest(http.MethodGet, dlURL, nil)
|
|
req.SetBasicAuth("testuser", "testpass")
|
|
rec = httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("basic-auth download should be 200, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouterDropperInstallScriptsPublic(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
for _, path := range []string{"/install.sh", "/install.ps1"} {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Host = "forge.local:8989"
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s status=%d", path, rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "AetherForge") {
|
|
t.Fatalf("%s missing branding", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRouterSPAFallbackUnknownRoute(t *testing.T) {
|
|
router, _, _, _ := newTestRouter(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/unknown-dashboard-route", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected SPA fallback 200, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "AetherForge") {
|
|
t.Fatal("expected index.html fallback")
|
|
}
|
|
}
|
|
|
|
func TestRouterNoWebRootFallback(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
seedTestUsers(t, dataDir)
|
|
database, err := db.New(dataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
|
|
wsHub := NewWSHub(database)
|
|
cfg := &mockConfigProvider{}
|
|
configHandler := NewConfigHandler(cfg)
|
|
aiHandler := NewAIHandler(database)
|
|
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
|
|
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
|
|
blueprintHandler := NewBlueprintHandler(dataDir)
|
|
|
|
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "No frontend configured") {
|
|
t.Fatalf("unexpected body: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
|
|
resetAuthState(t)
|
|
dataDir := t.TempDir()
|
|
|
|
LoadUsers(dataDir)
|
|
|
|
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
|
|
data, err := os.ReadFile(sidecarPath)
|
|
if err != nil {
|
|
t.Fatalf("login sidecar: %v", err)
|
|
}
|
|
var creds map[string]string
|
|
if err := json.Unmarshal(data, &creds); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pw, ok := creds["admin"]
|
|
if !ok || pw == "" {
|
|
t.Fatalf("expected admin password in sidecar: %v", creds)
|
|
}
|
|
if !checkPassword(authUsers["admin"], pw) {
|
|
t.Fatal("sidecar password should match users.json hash")
|
|
}
|
|
comradePW, ok := creds["comrade"]
|
|
if !ok || comradePW == "" {
|
|
t.Fatalf("expected comrade password in sidecar: %v", creds)
|
|
}
|
|
if !checkPassword(authUsers["comrade"], comradePW) {
|
|
t.Fatal("comrade sidecar password should match users.json hash")
|
|
}
|
|
|
|
if err := saveUser("admin", "new-secret-pass"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reloaded, err := readLoginSidecar(sidecarPath)
|
|
if err != nil || reloaded["admin"] != "new-secret-pass" {
|
|
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err)
|
|
}
|
|
}
|
|
|
|
func TestLoadUsersAddsComradeToExistingAdminDeck(t *testing.T) {
|
|
resetAuthState(t)
|
|
dataDir := t.TempDir()
|
|
adminHash, err := hashPassword("keep-admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
usersPath := filepath.Join(dataDir, "users.json")
|
|
if err := os.WriteFile(usersPath, []byte(fmt.Sprintf(`{"admin":%q}`, adminHash)), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sidecarPath := filepath.Join(dataDir, "login-credentials.json")
|
|
if err := writeLoginSidecar(sidecarPath, map[string]string{"admin": "keep-admin"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
LoadUsers(dataDir)
|
|
|
|
usersMu.RLock()
|
|
_, hasComrade := authUsers["comrade"]
|
|
usersMu.RUnlock()
|
|
if !hasComrade {
|
|
t.Fatal("expected comrade in users.json after startup")
|
|
}
|
|
creds, err := readLoginSidecar(sidecarPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if creds["admin"] != "keep-admin" {
|
|
t.Fatalf("admin sidecar password changed: %q", creds["admin"])
|
|
}
|
|
comradePW, ok := creds["comrade"]
|
|
if !ok || comradePW == "" {
|
|
t.Fatalf("expected comrade in sidecar: %v", creds)
|
|
}
|
|
if !checkPassword(authUsers["comrade"], comradePW) {
|
|
t.Fatal("comrade sidecar password should match users.json hash")
|
|
}
|
|
}
|