package server import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "path/filepath" "strings" "testing" "time" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/engine" "jarl/internal/model" "jarl/internal/render" "jarl/internal/stats" "jarl/internal/store" ) type apiEnv struct { t *testing.T ts *httptest.Server st *store.Store token string // admin token } // newAPIEnv starts an API server; a statistics collector may be given. func newAPIEnv(t *testing.T, collector ...*stats.Collector) *apiEnv { t.Helper() dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "jarl.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) eng := engine.New(st, nil, engine.Config{ Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}, StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true, }, quiet) cfg := Config{Version: "test"} if len(collector) > 0 { cfg.Stats = collector[0] } srv := New(cfg, eng, quiet, nil) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) hash, _ := auth.HashPassword("correct horse battery") admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true} st.CreateUser(context.Background(), admin) tok, pid, sh := auth.NewToken() st.CreateToken(context.Background(), &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh}) env := &apiEnv{t: t, ts: ts, st: st, token: tok} env.do("PATCH", "/settings", env.token, map[string]any{"nameservers": []string{"ns1.example.com."}}, 200) return env } // do sends a request with a bearer token and checks the status. func (e *apiEnv) do(method, path, token string, body any, want int) map[string]any { e.t.Helper() out, status := e.req(method, path, token, body) if status != want { e.t.Fatalf("%s %s = %d, want %d: %s", method, path, status, want, out) } if len(out) == 0 { return nil } var m map[string]any if json.Unmarshal(out, &m) != nil { return map[string]any{"_raw": string(out)} } return m } func (e *apiEnv) req(method, path, token string, body any) ([]byte, int) { var rd io.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } r, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd) if token != "" { r.Header.Set("Authorization", "Bearer "+token) } r.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(r) if err != nil { e.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return b, resp.StatusCode } func (e *apiEnv) list(path, token string) []map[string]any { e.t.Helper() b, status := e.req("GET", path, token, nil) if status != 200 { e.t.Fatalf("GET %s = %d: %s", path, status, b) } var out []map[string]any json.Unmarshal(b, &out) return out } func TestRecordsAPI(t *testing.T) { e := newAPIEnv(t) e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) res := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10", "ttl": 300}, 201) obj := res["object"].(map[string]any) if obj["name"] != "www.example.com." || obj["relative_name"] != "www" || obj["effective_ttl"].(float64) != 300 { t.Errorf("object = %v", obj) } id := int64(obj["id"].(float64)) // The CNAME rule, over HTTP. b, status := e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "CNAME", "data": "ns1"}) if status != 422 || !strings.Contains(string(b), "cname_conflict") { t.Errorf("CNAME next to A: %d %s", status, b) } // Bad data is a 400 with a clear message. b, status = e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "x", "type": "A", "data": "1.2.3"}) if status != 400 || !strings.Contains(string(b), "invalid A data") { t.Errorf("bad data: %d %s", status, b) } // TXT as plain text. e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "@", "type": "TXT", "data": "v=spf1 mx -all"}, 201) recs := e.list("/zones/example.com/records?type=TXT", e.token) if len(recs) != 1 || recs[0]["data"] != `"v=spf1 mx -all"` || recs[0]["text"] != "v=spf1 mx -all" { t.Errorf("TXT = %v", recs) } // Update, dry run first. res = e.do("PATCH", "/records/"+itoa(id)+"?dry_run=1", e.token, map[string]any{"data": "192.0.2.11"}, 200) if res["applied"] == true || !strings.Contains(res["result"].(map[string]any)["diff"].(string), "192.0.2.11") { t.Errorf("dry run = %v", res) } e.do("PATCH", "/records/"+itoa(id), e.token, map[string]any{"data": "192.0.2.11"}, 200) got := e.do("GET", "/records/"+itoa(id), e.token, nil, 200) if got["data"] != "192.0.2.11" { t.Errorf("after update = %v", got) } // RRset replace and delete by match. e.do("PUT", "/zones/example.com/rrsets/api/A", e.token, map[string]any{"ttl": 60, "data": []string{"192.0.2.30", "192.0.2.31"}}, 200) if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 2 { t.Errorf("rrset has %d records", n) } e.do("DELETE", "/zones/example.com/records?name=api&type=A&data=192.0.2.30", e.token, nil, 200) e.do("DELETE", "/zones/example.com/rrsets/api/A", e.token, nil, 200) if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 0 { t.Errorf("rrset not deleted: %d", n) } // Zone file. b, status = e.req("GET", "/zones/example.com/file", e.token, nil) if status != 200 || !strings.Contains(string(b), "www\t\tIN\tA\t192.0.2.11") && !strings.Contains(string(b), "www\t300\tIN\tA\t192.0.2.11") { t.Errorf("zone file %d:\n%s", status, b) } // History and revert. cs := e.list("/changesets?status=applied", e.token) if len(cs) < 5 { t.Errorf("changesets = %d", len(cs)) } if strings.Contains(string(mustJSON(cs)), `"undo"`) { t.Error("undo ops leaked in changeset list") } } func itoa(n int64) string { return strings.TrimSpace(strings.TrimSuffix(string(mustJSON(n)), "\n")) } func mustJSON(v any) []byte { b, _ := json.Marshal(v) return b } func TestStagingAPI(t *testing.T) { e := newAPIEnv(t) e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) cs := e.do("POST", "/changesets", e.token, map[string]any{"description": "web refresh"}, 201) id := itoa(int64(cs["id"].(float64))) res := e.do("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 202) if res["staged"] != true { t.Errorf("not staged: %v", res) } if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 0 { t.Error("staged record visible before apply") } prev := e.do("GET", "/changesets/"+id+"/preview", e.token, nil, 200) if prev["valid"] != true || !strings.Contains(prev["diff"].(string), "192.0.2.10") { t.Errorf("preview = %v", prev) } e.do("POST", "/changesets/"+id+"/apply", e.token, nil, 200) if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 1 { t.Error("record missing after apply") } b, status := e.req("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "x", "type": "A", "data": "192.0.2.2"}) if status != 409 { t.Errorf("staging into applied changeset: %d %s", status, b) } // Batch apply is atomic. b, status = e.req("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{ {"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "A", "data": "192.0.2.3"}}, {"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "CNAME", "data": "www"}}, }}) if status != 422 { t.Errorf("bad batch: %d %s", status, b) } if n := len(e.list("/zones/example.com/records?name=a", e.token)); n != 0 { t.Error("part of a failed batch was applied") } } func TestSessionsAndPermissions(t *testing.T) { e := newAPIEnv(t) e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) e.do("POST", "/zones", e.token, map[string]any{"name": "secret.org", "nameservers": []string{"ns1.example.com."}}, 201) // Unauthenticated. if _, status := e.req("GET", "/zones", "", nil); status != 401 { t.Errorf("unauthenticated = %d", status) } if _, status := e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil); status != 401 { t.Errorf("bad token = %d", status) } // An ACME user with a restricted token. e.do("POST", "/users", e.token, map[string]any{"username": "acme", "password": "acme-password-123", "grant_strings": []string{"records:write zones=example.com names=_acme-challenge.* types=TXT"}}, 201) users := e.list("/users", e.token) var acmeID int64 for _, u := range users { if u["username"] == "acme" { acmeID = int64(u["id"].(float64)) } } tok := e.do("POST", "/users/"+itoa(acmeID)+"/tokens", e.token, map[string]any{"name": "certbot", "expires_in": "90d"}, 201) acme := tok["token"].(string) e.do("POST", "/zones/example.com/records", acme, map[string]any{"name": "_acme-challenge.www", "type": "TXT", "data": "token"}, 201) if _, status := e.req("POST", "/zones/example.com/records", acme, map[string]any{"name": "www", "type": "A", "data": "192.0.2.66"}); status != 403 { t.Errorf("acme wrote an A record: %d", status) } zones := e.list("/zones", acme) if len(zones) != 1 || zones[0]["name"] != "example.com." { t.Errorf("acme sees zones %v", zones) } if recs := e.list("/zones/example.com/records", acme); len(recs) != 1 { t.Errorf("acme sees %d records", len(recs)) } if _, status := e.req("GET", "/zones/secret.org", acme, nil); status != 404 { t.Errorf("acme can see secret.org: %d", status) } if _, status := e.req("GET", "/users", acme, nil); status != 403 { t.Errorf("acme lists users: %d", status) } // Session login with CSRF protection. jar, _ := cookiejar.New(nil) c := &http.Client{Jar: jar} post := func(path, csrf string, body any) *http.Response { b, _ := json.Marshal(body) r, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b)) r.Header.Set("Content-Type", "application/json") if csrf != "" { r.Header.Set(csrfHeader, csrf) } resp, err := c.Do(r) if err != nil { t.Fatal(err) } return resp } resp := post("/auth/login", "", map[string]string{"username": "admin", "password": "wrong"}) if resp.StatusCode != 401 { t.Errorf("wrong password = %d", resp.StatusCode) } resp = post("/auth/login", "", map[string]string{"username": "ADMIN", "password": "correct horse battery"}) var who api.Whoami json.NewDecoder(resp.Body).Decode(&who) resp.Body.Close() if resp.StatusCode != 200 || who.CSRFToken == "" || !who.Admin { t.Fatalf("login = %d %+v", resp.StatusCode, who) } if r := post("/zones/example.com/records", "", map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 403 { t.Errorf("missing CSRF = %d", r.StatusCode) } if r := post("/zones/example.com/records", who.CSRFToken, map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 201 { b, _ := io.ReadAll(r.Body) t.Errorf("with CSRF = %d %s", r.StatusCode, b) } cs := e.list("/changesets?status=applied", e.token) if cs[0]["via"] != "web" || cs[0]["username"] != "admin" { t.Errorf("last changeset = %v", cs[0]) } if r := post("/auth/logout", who.CSRFToken, nil); r.StatusCode != 204 { t.Errorf("logout = %d", r.StatusCode) } resp2, _ := c.Get(e.ts.URL + api.Prefix + "/auth/whoami") if resp2.StatusCode != 401 { t.Errorf("after logout = %d", resp2.StatusCode) } // Audit trail. evs := e.list("/audit?action=auth.", e.token) if len(evs) < 3 { t.Errorf("auth audit events = %d", len(evs)) } // Login rate limiting. for i := 0; i < 10; i++ { post("/auth/login", "", map[string]string{"username": "acme", "password": "nope"}) } if r := post("/auth/login", "", map[string]string{"username": "acme", "password": "acme-password-123"}); r.StatusCode != 429 { t.Errorf("rate limit = %d", r.StatusCode) } } func TestTSIGSecrets(t *testing.T) { e := newAPIEnv(t) res := e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201) obj := res["object"].(map[string]any) if s, _ := obj["secret"].(string); len(s) != 44 { t.Errorf("generated secret not returned once: %v", obj) } list := e.list("/tsig-keys", e.token) if _, has := list[0]["secret"]; has { t.Error("secret in list") } k := e.do("GET", "/tsig-keys/xfer?secret=1", e.token, nil, 200) if len(k["secret"].(string)) != 44 { t.Error("secret not revealed") } ops := e.list("/changesets?status=applied", e.token)[0]["ops"].([]any) if !strings.Contains(string(mustJSON(ops)), "(hidden)") { t.Errorf("secret not redacted in changeset: %s", mustJSON(ops)) } _ = time.Now }