// Copyright 2020-2026 The happyDomain Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "sync" "testing" "time" "git.happydns.org/checker-sdk-go/checker" ) // --- test doubles --- type testProvider struct { key checker.ObservationKey collectFn func(ctx context.Context, opts checker.CheckerOptions) (any, error) definition *checker.CheckerDefinition htmlFn func(raw json.RawMessage) (string, error) metricsFn func(raw json.RawMessage, t time.Time) ([]checker.CheckMetric, error) } func (p *testProvider) Key() checker.ObservationKey { return p.key } func (p *testProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { if p.collectFn != nil { return p.collectFn(ctx, opts) } return map[string]string{"result": "ok"}, nil } func (p *testProvider) Definition() *checker.CheckerDefinition { return p.definition } func (p *testProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { if p.htmlFn != nil { return p.htmlFn(ctx.Data()) } return "
hello
", nil }, } srv := newTestServer(p) rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), }, map[string]string{"Accept": "text/html"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report html = %d, want %d", rec.Code, http.StatusOK) } if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { t.Errorf("Content-Type = %q, want text/html", ct) } if body := rec.Body.String(); body != "hello
" { t.Errorf("body = %q, want \"hello
\"", body) } } func TestServer_Report_Metrics(t *testing.T) { p := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := newTestServer(p) rec := doRequest(srv.Handler(), "POST", "/report", checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), }, map[string]string{"Accept": "application/json"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report metrics = %d, want %d", rec.Code, http.StatusOK) } var metrics []checker.CheckMetric json.NewDecoder(rec.Body).Decode(&metrics) if len(metrics) != 1 { t.Errorf("metrics count = %d, want 1", len(metrics)) } } // TestServer_Report_Related verifies the remote /report path wires // ExternalReportRequest.Related through to the provider's ReportContext, // the fix for the "remote checkers can't see related observations" gap. func TestServer_Report_Related(t *testing.T) { var gotRelated []checker.RelatedObservation p := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } // Replace htmlFn with one that peeks at a related key. We can't do that // directly through testProvider's htmlFn (which only sees raw), so // bind to GetHTMLReport via an inline wrapper: use a per-test provider // that captures the ReportContext before delegating to the template. srv := New(&relatedPeekingProvider{ base: p, target: &gotRelated, }) defer srv.Close() req := checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), Related: map[checker.ObservationKey][]checker.RelatedObservation{ "tls_probes": { {CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"}, }, }, } rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report = %d, want 200", rec.Code) } if len(gotRelated) != 1 { t.Fatalf("provider saw %d related observations, want 1", len(gotRelated)) } if gotRelated[0].CheckerID != "tls" || string(gotRelated[0].Data) != `{"ok":true}` { t.Errorf("related mismatch: got %+v", gotRelated[0]) } } // relatedPeekingProvider forwards to a base testProvider but copies the // Related("tls_probes") slice observed at GetHTMLReport time into target. type relatedPeekingProvider struct { base *testProvider target *[]checker.RelatedObservation } func (p *relatedPeekingProvider) Key() checker.ObservationKey { return p.base.Key() } func (p *relatedPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { return p.base.Collect(ctx, opts) } func (p *relatedPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition } func (p *relatedPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { *p.target = ctx.Related("tls_probes") return "ok
", nil } // statesPeekingProvider captures the ReportContext's States slice at // GetHTMLReport / ExtractMetrics time. type statesPeekingProvider struct { base *testProvider htmlSeen *[]checker.CheckState metricSeen *[]checker.CheckState } func (p *statesPeekingProvider) Key() checker.ObservationKey { return p.base.Key() } func (p *statesPeekingProvider) Collect(ctx context.Context, opts checker.CheckerOptions) (any, error) { return p.base.Collect(ctx, opts) } func (p *statesPeekingProvider) Definition() *checker.CheckerDefinition { return p.base.definition } func (p *statesPeekingProvider) GetHTMLReport(ctx checker.ReportContext) (string, error) { if p.htmlSeen != nil { *p.htmlSeen = ctx.States() } return "ok
", nil } func (p *statesPeekingProvider) ExtractMetrics(ctx checker.ReportContext, t time.Time) ([]checker.CheckMetric, error) { if p.metricSeen != nil { *p.metricSeen = ctx.States() } return []checker.CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil } // TestServer_Report_States_HTML verifies ExternalReportRequest.States is // threaded into the ReportContext seen by the HTML reporter. func TestServer_Report_States_HTML(t *testing.T) { var seen []checker.CheckState base := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) defer srv.Close() states := []checker.CheckState{ {Status: checker.StatusCrit, Message: "broken", RuleName: "r1", Code: "bad", Subject: "host.example"}, } req := checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), States: states, } rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report = %d, want 200", rec.Code) } if len(seen) != 1 || seen[0].RuleName != "r1" || seen[0].Code != "bad" || seen[0].Subject != "host.example" { t.Errorf("reporter saw states = %+v, want single state {RuleName:r1, Code:bad, Subject:host.example}", seen) } } // TestServer_Report_States_Metrics verifies the States passthrough on the // metrics path as well. func TestServer_Report_States_Metrics(t *testing.T) { var seen []checker.CheckState base := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := New(&statesPeekingProvider{base: base, metricSeen: &seen}) defer srv.Close() req := checker.ExternalReportRequest{ Key: "test", Data: json.RawMessage(`{}`), States: []checker.CheckState{{Status: checker.StatusWarn, RuleName: "r1"}}, } rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "application/json"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report = %d, want 200", rec.Code) } if len(seen) != 1 || seen[0].RuleName != "r1" { t.Errorf("reporter saw states = %+v, want single state with RuleName=r1", seen) } } // TestServer_Report_States_Absent verifies that omitting States in the // request yields a nil States() slice on the reporter side (graceful // degradation for hosts that don't thread evaluate→report yet). func TestServer_Report_States_Absent(t *testing.T) { seen := []checker.CheckState{{Status: checker.StatusOK}} // non-nil sentinel base := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := New(&statesPeekingProvider{base: base, htmlSeen: &seen}) defer srv.Close() req := checker.ExternalReportRequest{Key: "test", Data: json.RawMessage(`{}`)} rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"}) if rec.Code != http.StatusOK { t.Fatalf("POST /report = %d, want 200", rec.Code) } if seen != nil { t.Errorf("States() = %+v, want nil when ExternalReportRequest.States is absent", seen) } } func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ID: "test-checker", Rules: []checker.CheckRule{}}, } srv := newTestServer(p) req := httptest.NewRequest("POST", "/report", bytes.NewBufferString("{bad")) rec := httptest.NewRecorder() srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("POST /report bad body = %d, want %d", rec.Code, http.StatusBadRequest) } } func TestServer_NoDefinition_NoEvaluateEndpoint(t *testing.T) { // A provider that does NOT implement CheckerDefinitionProvider p := &stubProvider{key: "basic"} srv := New(p) rec := doRequest(srv.Handler(), "POST", "/evaluate", nil, nil) // Should 404 or 405 since /evaluate is not registered if rec.Code == http.StatusOK { t.Error("POST /evaluate should not be available without CheckerDefinitionProvider") } } // prereqRule implements RulePrecheck, failing when a named option is empty. type prereqRule struct { name string optKey string msg string } func (r *prereqRule) Name() string { return r.name } func (r *prereqRule) Description() string { return "" } func (r *prereqRule) Evaluate(ctx context.Context, obs checker.ObservationGetter, opts checker.CheckerOptions) []checker.CheckState { return []checker.CheckState{{Status: checker.StatusOK}} } func (r *prereqRule) Precheck(ctx context.Context, opts checker.CheckerOptions) error { if v, _ := opts[r.optKey].(string); v == "" { return errors.New(r.msg) } return nil } func TestServer_Precheck(t *testing.T) { gated := &prereqRule{name: "gated", optKey: "api_key", msg: "missing API key"} open := &dummyRule{name: "open", desc: "no prereq"} p := &testProvider{ key: "test", definition: &checker.CheckerDefinition{ ID: "test", Rules: []checker.CheckRule{gated, open}, }, } srv := newTestServer(p) defer srv.Close() handler := srv.Handler() // GET /definition stays static — no precheck information surfaces here. rec := doRequest(handler, "GET", "/definition", nil, nil) if rec.Code != http.StatusOK { t.Fatalf("GET /definition = %d, want %d", rec.Code, http.StatusOK) } if bytes.Contains(rec.Body.Bytes(), []byte("precheck_failures")) { t.Errorf("GET /definition leaked precheck_failures field: %s", rec.Body.String()) } // POST /definition with empty opts: gated rule fails, open rule absent. rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{Options: checker.CheckerOptions{}}, nil) if rec.Code != http.StatusOK { t.Fatalf("POST /definition (empty opts) = %d, want %d", rec.Code, http.StatusOK) } var resp checker.RulePrecheckResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode POST /definition: %v", err) } if resp.CheckerDefinition == nil { t.Fatalf("POST /definition response missing embedded CheckerDefinition") } if resp.ID != "test" { t.Errorf("response ID = %q, want %q", resp.ID, "test") } if len(resp.RulesInfo) != 2 { t.Errorf("response RulesInfo len = %d, want 2", len(resp.RulesInfo)) } if got := resp.PrecheckFailures["gated"]; got != "missing API key" { t.Errorf("PrecheckFailures[gated] = %q, want %q", got, "missing API key") } if _, ok := resp.PrecheckFailures["open"]; ok { t.Errorf("PrecheckFailures[open] should be absent (no RulePrecheck impl), got %q", resp.PrecheckFailures["open"]) } if len(resp.PrecheckFailures) != 1 { t.Errorf("PrecheckFailures = %v, want exactly 1 entry", resp.PrecheckFailures) } // POST /definition with sufficient opts: empty failure map. rec = doRequest(handler, "POST", "/definition", checker.RulePrecheckRequest{ Options: checker.CheckerOptions{"api_key": "secret"}, }, nil) if rec.Code != http.StatusOK { t.Fatalf("POST /definition (with opts) = %d, want %d", rec.Code, http.StatusOK) } resp = checker.RulePrecheckResponse{} if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode POST /definition: %v", err) } if resp.CheckerDefinition == nil || resp.ID != "test" { t.Errorf("response missing definition: %+v", resp) } if len(resp.PrecheckFailures) != 0 { t.Errorf("PrecheckFailures = %v, want empty when opts satisfy prereqs", resp.PrecheckFailures) } }