package api import ( "bytes" "encoding/json" "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) } } 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 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, nil), nil, nil, "", dataDir, 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, "", dataDir, 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") } 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) } }