package handlers import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "teraclone/internal/service" ) func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { t.Error("credentials leaked through public login request") } switch r.URL.Path { case "/api/v1/auth/startup": _ = json.NewEncoder(w).Encode(map[string]any{"ready": true, "state": "ready"}) case "/api/v1/auth/login-policy": _ = json.NewEncoder(w).Encode(map[string]bool{"captcha_enabled": true}) case "/api/v1/auth/captcha": if r.Header.Get("X-Forwarded-For") != "192.0.2.44" { t.Errorf("challenge peer = %q", r.Header.Get("X-Forwarded-For")) } _ = json.NewEncoder(w).Encode(map[string]string{"challenge_id": "test-challenge", "image_data_url": "data:image/png;base64,test", "expires_at": "2026-09-07T12:00:00Z"}) case "/api/v1/auth/login": var input map[string]string _ = json.NewDecoder(r.Body).Decode(&input) if r.Header.Get("X-Forwarded-For") != "192.0.2.44" { t.Error("login and challenge peers differ") } if input["browser_time"] != "2026-09-08T03:00:00Z" { t.Error("browser time was not forwarded") } if input["captcha_id"] != "test-challenge" || input["captcha_answer"] != "23456789" { w.WriteHeader(401) _, _ = w.Write([]byte(`{"error":{"code":"captcha_failed","message":"login verification failed"}}`)) return } _ = json.NewEncoder(w).Encode(map[string]string{"actor": "admin", "role": "administrator", "session_token": "private-token"}) default: http.NotFound(w, r) } })) defer backend.Close() app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db")) if err != nil { t.Fatal(err) } defer app.Close() handler, err := NewDeviceHandler(app, backend.URL) if err != nil { t.Fatal(err) } mux := http.NewServeMux() handler.RegisterRoutes(mux) for _, path := range []string{"/login/backend-status", "/login/captcha"} { method := http.MethodGet if path == "/login/captcha" { method = http.MethodPost } req := httptest.NewRequest(method, path, nil) req.RemoteAddr = "192.0.2.44:45000" req.Header.Set("X-Forwarded-For", "198.51.100.99") req.Header.Set("Authorization", "Bearer untrusted") req.Header.Set("Cookie", "untrusted=yes") res := httptest.NewRecorder() mux.ServeHTTP(res, req) if res.Code != 200 || res.Header().Get("Cache-Control") != "no-store" { t.Fatalf("public endpoint = %d: %s", res.Code, res.Body.String()) } if path == "/login/backend-status" && !strings.Contains(res.Body.String(), `"captcha_enabled":true`) { t.Fatal("CAPTCHA policy lost") } } for _, answer := range []string{"wrong", "23456789"} { body := url.Values{"username": {"admin"}, "password": {"test-password"}, "captcha_id": {"test-challenge"}, "captcha_answer": {answer}, "browser_time": {"2026-09-08T03:00:00Z"}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body.Encode())) req.RemoteAddr = "192.0.2.44:45001" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") res := httptest.NewRecorder() mux.ServeHTTP(res, req) if answer == "wrong" { if len(res.Result().Cookies()) != 0 || !strings.Contains(res.Body.String(), "틀렸거나 만료") { t.Fatal("CAPTCHA failure did not remain on login") } } else if res.Code != 303 || len(res.Result().Cookies()) == 0 { t.Fatalf("valid challenge login = %d", res.Code) } if strings.Contains(res.Body.String(), "test-password") || strings.Contains(res.Body.String(), "private-token") { t.Fatal("login response leaked secret") } } } func TestFactoryResetPageRequiresAdministratorAndExplainsScope(t *testing.T) { app := service.NewStatelessAppService() defer app.Close() handler, err := NewDeviceHandler(app, "http://127.0.0.1:1") if err != nil { t.Fatal(err) } mux := http.NewServeMux() handler.RegisterRoutes(mux) for _, role := range []string{"administrator", "port_operator"} { handler.sessions[role] = browserSession{Username: "test", BackendToken: "test", Role: role} req := httptest.NewRequest(http.MethodGet, "/device/factory-reset", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: role}) res := httptest.NewRecorder() mux.ServeHTTP(res, req) if role == "port_operator" { if res.Code != 403 { t.Fatal("operator can open factory reset") } continue } for _, expected := range []string{"공장 초기화", `name="password"`, "terauser / terauser", "자동 재부팅됩니다"} { if !strings.Contains(res.Body.String(), expected) { t.Errorf("missing reset warning: %s", expected) } } if strings.Contains(res.Body.String(), `href="/ports/restart"`) { t.Fatal("old restart sidebar entry still present") } } }