package web import ( "bytes" "encoding/json" "log" "net/http" "net/http/httptest" "net/url" "strings" "sync/atomic" "testing" ) // R-138 option C (operator ruling 2026-10-08, `09` §3 decision 190): a pasted Cloudflare API token is saved only when // Cloudflare says it sees exactly this customer's own zone. A fake Cloudflare (httptest) stands in for the API; no // real call is made. The CONSEQUENCE asserted is the stored config: a refused token is not stored, and on edit the // previous token stays. // // RED-PROOF: remove the cfTokenReachMessage block from handleConfigCreate → TestR138_CreateRefusesWideToken stores // customer „b" with the account-wide token → FAILS. const ( tokOwn = "tok-own-zone-0123456789abcdef" tokWide = "tok-account-wide-0123456789abcdef" tokOther = "tok-other-zone-0123456789abcdef" tokNone = "tok-sees-nothing-0123456789abcdef" tokOwn2 = "tok-own-zone-second-0123456789abcdef" ) type fakeCF struct { srv *httptest.Server calls atomic.Int32 down atomic.Bool } func newFakeCF(t *testing.T) *fakeCF { f := &fakeCF{} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.calls.Add(1) if f.down.Load() { http.Error(w, `{"success":false}`, http.StatusServiceUnavailable) return } if r.URL.Path != "/zones" { http.NotFound(w, r) return } var zones []string switch strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") { case tokOwn, tokOwn2: zones = []string{"example.hu"} case tokWide: zones = []string{"example.hu", "neighbour.hu"} case tokOther: zones = []string{"neighbour.hu"} case tokNone: zones = nil default: w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"success":false,"errors":[{"message":"Invalid API Token"}]}`)) return } res := []map[string]string{} for i, z := range zones { res = append(res, map[string]string{"id": "z" + string(rune('0'+i)), "name": z}) } json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": res, "result_info": map[string]int{"total_count": len(res)}}) })) t.Cleanup(f.srv.Close) return f } func r138Server(t *testing.T) (*Server, *fakeCF, *bytes.Buffer) { s, _ := newTestServer(t) f := newFakeCF(t) s.cfAPIBase = f.srv.URL var logs bytes.Buffer s.logger = log.New(&logs, "", 0) return s, f, &logs } func r138Create(s *Server, id, domain, token string) *httptest.ResponseRecorder { form := url.Values{"customer_id": {id}, "customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}} req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleConfigCreate(rr, req) return rr } func r138Edit(s *Server, id, domain, token string) *httptest.ResponseRecorder { form := url.Values{"customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}} req := httptest.NewRequest(http.MethodPost, "/configs/"+id, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleConfigUpdate(rr, req, id) return rr } func r138StoredToken(t *testing.T, s *Server, id string) (string, bool) { t.Helper() cfg, _ := s.store.GetCustomerConfig(id) if cfg == nil { return "", false } return storedCFToken(cfg.ConfigJSON), true } func TestR138_CreateAcceptsOwnZoneToken(t *testing.T) { s, f, _ := r138Server(t) if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther { t.Fatalf("own-zone token must be accepted; got %d %s", rr.Code, rr.Body.String()) } if tok, ok := r138StoredToken(t, s, "a"); !ok || tok != tokOwn { t.Fatalf("the own-zone token was not stored (stored=%v)", ok) } if f.calls.Load() != 1 { t.Errorf("expected exactly 1 Cloudflare call, got %d", f.calls.Load()) } // A domain UNDER the token's one zone is the customer's own too (the zone is the customer's, `01` §7). s2, _, _ := r138Server(t) if rr := r138Create(s2, "sub", "home.example.hu", tokOwn); rr.Code != http.StatusSeeOther { t.Errorf("a domain under the token's one zone must be accepted; got %d %s", rr.Code, rr.Body.String()) } } func TestR138_CreateRefusesWideToken(t *testing.T) { s, _, _ := r138Server(t) cases := []struct{ id, tok, want string }{ {"b", tokWide, "reaches 2 zones"}, {"c", tokOther, "neighbour.hu"}, {"d", tokNone, "sees no zone"}, {"e", "tok-rejected-by-cloudflare-0123456789", "could not be asked"}, } for _, c := range cases { rr := r138Create(s, c.id, "example.hu", c.tok) if rr.Code == http.StatusSeeOther { t.Errorf("%s: token was accepted — it must be refused", c.id) } if !strings.Contains(rr.Body.String(), c.want) { t.Errorf("%s: the refusal must say %q; got %d", c.id, c.want, rr.Code) } if _, ok := r138StoredToken(t, s, c.id); ok { t.Errorf("%s: a config was stored for a refused token", c.id) } } } func TestR138_EditRefusedTokenKeepsOldToken(t *testing.T) { s, f, _ := r138Server(t) if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther { t.Fatalf("create: %d", rr.Code) } // A wide token on edit: refused, old token stays. if rr := r138Edit(s, "a", "example.hu", tokWide); !strings.Contains(rr.Body.String(), "Nothing was saved") { t.Errorf("wide token on edit must be refused; got %d", rr.Code) } if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn { t.Errorf("after a refused edit the previous token must stay") } // Cloudflare down: refused, old token stays. f.down.Store(true) if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") { t.Errorf("Cloudflare down must refuse with the clear sentence; got %d", rr.Code) } if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn { t.Errorf("while Cloudflare is down the previous token must stay") } // Unreachable (server closed): refused too. f.down.Store(false) f.srv.Close() if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") { t.Errorf("unreachable Cloudflare must refuse; got %d", rr.Code) } if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn { t.Errorf("while Cloudflare is unreachable the previous token must stay") } } func TestR138_UnchangedOrEmptyTokenMakesNoCall(t *testing.T) { s, f, _ := r138Server(t) if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther { t.Fatalf("create: %d", rr.Code) } before := f.calls.Load() f.down.Store(true) // any call would now refuse — so a successful save proves no call was needed if rr := r138Edit(s, "a", "example.hu", tokOwn); strings.Contains(rr.Body.String(), "Nothing was saved") || strings.Contains(rr.Body.String(), "previous token stays") { t.Errorf("an unchanged token on an unchanged domain must not be checked; got %s", rr.Body.String()) } if f.calls.Load() != before { t.Errorf("unchanged token made %d Cloudflare call(s)", f.calls.Load()-before) } // Empty token (HTTP-01): no call, on create and on edit. if rr := r138Create(s, "h", "http01.hu", ""); rr.Code != http.StatusSeeOther { t.Errorf("empty token create must be accepted; got %d", rr.Code) } if rr := r138Edit(s, "a", "example.hu", ""); strings.Contains(rr.Body.String(), "previous token stays") { t.Errorf("clearing the token must not be checked") } if f.calls.Load() != before { t.Errorf("empty token made %d Cloudflare call(s)", f.calls.Load()-before) } } func TestR138_TokenNeverInLogsOrPage(t *testing.T) { s, f, logs := r138Server(t) var pages strings.Builder for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone} { rr := r138Create(s, "x"+tok[4:8], "example.hu", tok) if rr.Code != http.StatusSeeOther { pages.WriteString(rr.Body.String()) } } f.down.Store(true) pages.WriteString(r138Create(s, "y", "example.hu", tokOwn2).Body.String()) for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone, tokOwn2} { if strings.Contains(logs.String(), tok) { t.Errorf("a token appeared in the log") } } if !strings.Contains(logs.String(), "cloudflare token check") { t.Errorf("positive control: the check must log its outcome; log was %q", logs.String()) } // The form echoes what the operator typed (as it always has), but the refusal SENTENCE never carries the token. for _, line := range strings.Split(pages.String(), "\n") { if strings.Contains(line, "Nothing was saved") || strings.Contains(line, "previous token stays") { for _, tok := range []string{tokWide, tokOther, tokNone, tokOwn2} { if strings.Contains(line, tok) { t.Errorf("a refusal sentence carried the token") } } } } }