Files
felhom.eu/hub/internal/web/r138_cf_token_reach_test.go
T
admin 700a2d06fe hub: check a pasted Cloudflare token's reach before saving it (R-138 option C, decision 190)
On create and edit, a non-empty cf_api_token is checked with Cloudflare (GET /zones): it is saved only when
the token sees exactly one zone and the customer's domain is that zone or a name under it. More zones, another
zone, no zone, or Cloudflare not answering -> the form re-renders with one sentence and nothing is saved (the
previous token stays). An unchanged token on an unchanged domain and an empty token (HTTP-01) make no call.
The token is never logged and never in a sentence or error. Tests use a fake Cloudflare (httptest).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:17:05 +02:00

236 lines
8.6 KiB
Go

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")
}
}
}
}
}