package server import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.mchus.pro/mchus/logpile/internal/models" ) func newCollectTestServer() (*Server, *httptest.Server) { s := &Server{ jobManager: NewJobManager(), collectors: testCollectorRegistry(), } mux := http.NewServeMux() mux.HandleFunc("POST /api/collect", s.handleCollectStart) mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus) mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel) return s, httptest.NewServer(mux) } func TestCollectLifecycleToTerminal(t *testing.T) { _, ts := newCollectTestServer() defer ts.Close() body := `{"host":"bmc01.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { t.Fatalf("expected 202, got %d", resp.StatusCode) } var created CollectJobResponse if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode create response: %v", err) } if created.JobID == "" { t.Fatalf("expected job id") } status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) if status.Status != CollectStatusSuccess { t.Fatalf("expected success, got %q (error=%q)", status.Status, status.Error) } if status.Progress == nil || *status.Progress != 100 { t.Fatalf("expected progress 100, got %#v", status.Progress) } if len(status.Logs) < 4 { t.Fatalf("expected detailed logs, got %v", status.Logs) } } func TestCollectCancel(t *testing.T) { _, ts := newCollectTestServer() defer ts.Close() body := `{"host":"bmc02.local","protocol":"ipmi","port":623,"username":"operator","auth_type":"token","token":"keep-me-secret","tls_mode":"insecure"}` resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect failed: %v", err) } defer resp.Body.Close() var created CollectJobResponse if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode create response: %v", err) } cancelResp, err := http.Post(ts.URL+"/api/collect/"+created.JobID+"/cancel", "application/json", nil) if err != nil { t.Fatalf("cancel collect failed: %v", err) } defer cancelResp.Body.Close() if cancelResp.StatusCode != http.StatusOK { t.Fatalf("expected 200 cancel, got %d", cancelResp.StatusCode) } var canceled CollectJobStatusResponse if err := json.NewDecoder(cancelResp.Body).Decode(&canceled); err != nil { t.Fatalf("decode cancel response: %v", err) } if canceled.Status != CollectStatusCanceled { t.Fatalf("expected canceled, got %q", canceled.Status) } time.Sleep(500 * time.Millisecond) final := getCollectStatus(t, ts.URL, created.JobID, http.StatusOK) if final.Status != CollectStatusCanceled { t.Fatalf("expected canceled to stay terminal, got %q", final.Status) } } func TestCollectNotFoundAndSecretLeak(t *testing.T) { _, ts := newCollectTestServer() defer ts.Close() notFound := getCollectStatus(t, ts.URL, "job_notfound123", http.StatusNotFound) if notFound.JobID != "" || notFound.Status != "" { t.Fatalf("unexpected body for not found: %+v", notFound) } cancelResp, err := http.Post(ts.URL+"/api/collect/job_notfound123/cancel", "application/json", nil) if err != nil { t.Fatalf("cancel not found request failed: %v", err) } cancelResp.Body.Close() if cancelResp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404 for cancel not found, got %d", cancelResp.StatusCode) } body := `{"host":"need-fail.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"ultra-secret","tls_mode":"strict"}` resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect failed: %v", err) } defer resp.Body.Close() var created CollectJobResponse if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode create response: %v", err) } status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) if status.Status != CollectStatusFailed { t.Fatalf("expected failed by host toggle, got %q", status.Status) } raw, err := json.Marshal(status) if err != nil { t.Fatalf("marshal status: %v", err) } if strings.Contains(string(raw), "ultra-secret") || strings.Contains(strings.Join(status.Logs, " "), "ultra-secret") { t.Fatalf("secret leaked into API response or logs") } } func TestCollectStartPreservesCurrentResultUntilSuccess(t *testing.T) { s, ts := newCollectTestServer() defer ts.Close() existing := &models.AnalysisResult{ Filename: "archive.tar.gz", SourceType: models.SourceTypeArchive, CollectedAt: time.Now().UTC(), } s.SetResult(existing) body := `{"host":"bmc-success.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect failed: %v", err) } defer resp.Body.Close() var created CollectJobResponse if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode create response: %v", err) } current := s.GetResult() if current != existing { t.Fatalf("expected current result to stay unchanged before success") } status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) if status.Status != CollectStatusSuccess { t.Fatalf("expected success, got %q", status.Status) } finalResult := s.GetResult() if finalResult == nil { t.Fatalf("expected result to be set on success") } if finalResult.SourceType != models.SourceTypeAPI { t.Fatalf("expected api source type after success, got %q", finalResult.SourceType) } if finalResult.TargetHost != "bmc-success.local" { t.Fatalf("expected target host to be updated, got %q", finalResult.TargetHost) } } func TestCollectFailedDoesNotOverwriteCurrentResult(t *testing.T) { s, ts := newCollectTestServer() defer ts.Close() existing := &models.AnalysisResult{ Filename: "still-archive.tar.gz", SourceType: models.SourceTypeArchive, CollectedAt: time.Now().UTC(), } s.SetResult(existing) body := `{"host":"contains-fail.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}` resp, err := http.Post(ts.URL+"/api/collect", "application/json", bytes.NewBufferString(body)) if err != nil { t.Fatalf("post collect failed: %v", err) } defer resp.Body.Close() var created CollectJobResponse if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode create response: %v", err) } status := waitForTerminalStatus(t, ts.URL, created.JobID, 4*time.Second) if status.Status != CollectStatusFailed { t.Fatalf("expected failed, got %q", status.Status) } finalResult := s.GetResult() if finalResult != existing { t.Fatalf("expected existing result to remain on failed job") } } func waitForTerminalStatus(t *testing.T, baseURL, jobID string, timeout time.Duration) CollectJobStatusResponse { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { status := getCollectStatus(t, baseURL, jobID, http.StatusOK) if status.Status == CollectStatusSuccess || status.Status == CollectStatusFailed || status.Status == CollectStatusCanceled { return status } time.Sleep(100 * time.Millisecond) } t.Fatalf("job %s did not reach terminal status before timeout", jobID) return CollectJobStatusResponse{} } func getCollectStatus(t *testing.T, baseURL, jobID string, expectedCode int) CollectJobStatusResponse { t.Helper() resp, err := http.Get(baseURL + "/api/collect/" + jobID) if err != nil { t.Fatalf("get collect status failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != expectedCode { t.Fatalf("expected status %d, got %d", expectedCode, resp.StatusCode) } if expectedCode != http.StatusOK { return CollectJobStatusResponse{} } var status CollectJobStatusResponse if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { t.Fatalf("decode collect status: %v", err) } return status }