package updateagent import ( "bytes" "context" "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol" ) func TestRedactSecretRemovesCredentialFromErrors(t *testing.T) { t.Parallel() const secret = "sensitive-registry-token" redacted := redactSecret(errors.New("registry rejected "+secret), secret) if strings.Contains(redacted.Error(), secret) || !strings.Contains(redacted.Error(), "[redacted]") { t.Fatalf("credential was not redacted: %q", redacted) } } type blockingRunner struct { started chan updateprotocol.Request release chan struct{} } func (r *blockingRunner) Update(ctx context.Context, request updateprotocol.Request, progress func(phase, message string)) error { progress(updateprotocol.PhasePulling, "image is being pulled") r.started <- request select { case <-ctx.Done(): return ctx.Err() case <-r.release: return nil } } func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) { t.Parallel() runner := &blockingRunner{started: make(chan updateprotocol.Request, 1), release: make(chan struct{})} controller := NewController(runner, slog.New(slog.NewTextHandler(io.Discard, nil))) handler := controller.Handler() invalid := httptest.NewRecorder() handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"stable","registry_username":"user","registry_token":"secret-token"}`))) if invalid.Code != http.StatusBadRequest { t.Fatalf("invalid version accepted: status=%d", invalid.Code) } trailing := httptest.NewRecorder() handler.ServeHTTP(trailing, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v1.2.3","registry_username":"user","registry_token":"secret-token"} {}`))) if trailing.Code != http.StatusBadRequest { t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code) } invalidCurrent := httptest.NewRecorder() handler.ServeHTTP(invalidCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"1.3","registry_username":"user","registry_token":"secret-token"}`))) if invalidCurrent.Code != http.StatusBadRequest { t.Fatalf("invalid current version accepted: status=%d", invalidCurrent.Code) } missingCurrent := httptest.NewRecorder() handler.ServeHTTP(missingCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","registry_username":"user","registry_token":"secret-token"}`))) if missingCurrent.Code != http.StatusBadRequest { t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code) } const secret = "registry-token-must-not-leak" body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`) accepted := httptest.NewRecorder() handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) if accepted.Code != http.StatusAccepted { t.Fatalf("valid update rejected: status=%d body=%s", accepted.Code, accepted.Body.String()) } select { case request := <-runner.started: if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret { t.Fatalf("runner received an altered request: %#v", request) } case <-time.After(time.Second): t.Fatal("update runner did not start") } busy := httptest.NewRecorder() handler.ServeHTTP(busy, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body))) if busy.Code != http.StatusConflict { t.Fatalf("parallel update was not rejected: status=%d", busy.Code) } status := httptest.NewRecorder() handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil)) if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) || !strings.Contains(status.Body.String(), `"protocol_version":2`) { t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String()) } close(runner.release) deadline := time.Now().Add(time.Second) for controller.Status().Phase != updateprotocol.PhaseComplete && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } if controller.Status().Phase != updateprotocol.PhaseComplete { t.Fatalf("update did not complete: %#v", controller.Status()) } }