Files
jarl/cmd/jarl/main_test.go
T
adamandClaude Opus 5.5 f716fb8dd1 Script-friendly CLI output, and keep record list rows on one line
-o id and -o field=NAME,... print just the IDs or chosen fields; -q on
show and list prints the IDs. jarl record list shortens data too long
for the terminal (--wide shows it in full).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:46:43 +00:00

235 lines
8.1 KiB
Go

package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go.yaml.in/yaml/v3"
)
func TestBatchFile(t *testing.T) {
src := `
description: test
changes:
- add: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
- delete: {zone: example.com, name: old, type: A}
- delete: {id: 12}
- set: {zone: example.com, name: api, type: A, data: 192.0.2.5}
- set: {zone: example.com, name: gone, type: TXT}
- update: {id: 13, data: 192.0.2.9}
- disable: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
- enable: {id: 14}
- op: zone.create
data: {name: lab.example.com}
`
var b batchFile
if err := yaml.Unmarshal([]byte(src), &b); err != nil {
t.Fatal(err)
}
reqs, err := b.requests()
if err != nil {
t.Fatal(err)
}
want := []struct {
op string
id int64
data string
}{
{"record.create", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
{"record.delete", 0, `{"name":"old","type":"A","zone":"example.com"}`},
{"record.delete", 12, `{}`},
{"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`},
{"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`},
{"record.update", 13, `{"data":"192.0.2.9"}`},
{"record.disable", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
{"record.enable", 14, `{}`},
{"zone.create", 0, `{"name":"lab.example.com"}`},
}
if len(reqs) != len(want) {
t.Fatalf("got %d requests, want %d", len(reqs), len(want))
}
for i, w := range want {
r := reqs[i]
// Compare data as parsed JSON so key order doesn't matter.
var got, exp any
json.Unmarshal(r.Data, &got)
json.Unmarshal([]byte(w.data), &exp)
gb, _ := json.Marshal(got)
eb, _ := json.Marshal(exp)
if r.Op != w.op || r.ID != w.id || string(gb) != string(eb) {
t.Errorf("request %d = %s %d %s, want %s %d %s", i, r.Op, r.ID, gb, w.op, w.id, eb)
}
}
}
func TestBatchFileErrors(t *testing.T) {
for _, src := range []string{
"changes: [{add: {name: a}, delete: {name: b}}]", // two actions in one entry
"changes: [{}]", // no action
"changes: [{update: {data: x}}]", // update without id
} {
var b batchFile
if err := yaml.Unmarshal([]byte(src), &b); err != nil {
t.Fatal(err)
}
if _, err := b.requests(); err == nil {
t.Errorf("%s: expected an error", src)
}
}
}
func TestChallengeName(t *testing.T) {
for _, tc := range []struct {
domain string
exact bool
want string
}{
{"example.com", false, "_acme-challenge.example.com."},
{"*.example.com", false, "_acme-challenge.example.com."},
{"WWW.Example.com.", false, "_acme-challenge.www.example.com."},
{"_acme-challenge.www.example.com", false, "_acme-challenge.www.example.com."}, // acme.sh passes the full name
{"www.acme.example.com", true, "www.acme.example.com."},
} {
o := &acmeOptions{exact: tc.exact}
got, err := o.challengeName(tc.domain)
if err != nil || got != tc.want {
t.Errorf("challengeName(%q, exact=%v) = %q, %v; want %q", tc.domain, tc.exact, got, err, tc.want)
}
}
if _, err := (&acmeOptions{}).challengeName(" "); err == nil {
t.Error("empty domain accepted")
}
}
func TestListValue(t *testing.T) {
if got := listValue([]string{"192.0.2.1", " 192.0.2.2; ", "key xfer"}); got != "{ 192.0.2.1; 192.0.2.2; key xfer; }" {
t.Errorf("listValue = %q", got)
}
}
// TestListQueries runs list commands against a fake server and checks the
// filters they send: flags bound to variables shared between subcommands
// must not leak defaults (zone create's --type primary once hid every
// non-primary zone from 'zone list').
func TestDisableCommands(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.Path + "?" + r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"applied":true,"changeset_id":1}`))
}))
defer srv.Close()
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
for _, tc := range []struct {
args []string
want string
}{
{[]string{"record", "disable", "example.com", "www", "A"}, "POST /api/v1/zones/example.com/records/disable?name=www&type=A"},
{[]string{"record", "disable", "example.com", "www", "A", "192.0.2.1", "--ptr", "--view", "internal"}, "POST /api/v1/zones/example.com/records/disable?data=192.0.2.1&name=www&ptr=1&type=A&view=internal"},
{[]string{"record", "enable", "--id", "12", "--ptr"}, "POST /api/v1/records/12/enable?ptr=1"},
{[]string{"record", "enable", "example.com", "old", "CNAME", "--missing-ok"}, "POST /api/v1/zones/example.com/records/enable?missing_ok=1&name=old&type=CNAME"},
} {
got = ""
root := rootCmd()
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
root.SetOut(io.Discard)
if err := root.Execute(); err != nil {
t.Errorf("%v: %v", tc.args, err)
continue
}
if got != tc.want {
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
}
}
}
func TestListQueries(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.Path + "?" + r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/stats/") && !strings.HasSuffix(r.URL.Path, "/top") && !strings.HasSuffix(r.URL.Path, "/breakdown") {
w.Write([]byte("{}"))
return
}
w.Write([]byte("[]"))
}))
defer srv.Close()
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
for _, tc := range []struct {
args []string
want string
}{
{[]string{"zone", "list"}, "/api/v1/zones?"},
{[]string{"zone", "list", "--type", "hint"}, "/api/v1/zones?type=hint"},
{[]string{"zone", "list", "--view", "internal"}, "/api/v1/zones?view=internal"},
{[]string{"changeset", "list"}, "/api/v1/changesets?status=open"},
{[]string{"option", "list", "--all"}, "/api/v1/options?"},
{[]string{"stats", "summary"}, "/api/v1/stats/summary?since=24h&top=10"},
{[]string{"stats", "heatmap"}, "/api/v1/stats/heatmap?key=queries&since=28d"},
{[]string{"stats", "breakdown", "rcode"}, "/api/v1/stats/breakdown?group=rcode&since=24h"},
{[]string{"stats", "top", "clients", "--since", "7d"}, "/api/v1/stats/top?kind=client&limit=20&since=7d"},
{[]string{"stats", "top", "nxdomain"}, "/api/v1/stats/top?kind=nxdomain&limit=20&since=24h"},
{[]string{"stats", "timeline", "--key", "queries", "--key", "rcode:*"}, "/api/v1/stats/timeline?key=queries&key=rcode%3A%2A&since=24h&step=auto"},
} {
got = ""
root := rootCmd()
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
root.SetOut(io.Discard)
if err := root.Execute(); err != nil {
t.Errorf("%v: %v", tc.args, err)
continue
}
if got != tc.want {
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
}
}
}
func TestOutputFields(t *testing.T) {
capture := func(f func() error) string {
t.Helper()
r, w, _ := os.Pipe()
old := os.Stdout
os.Stdout = w
err := f()
os.Stdout = old
w.Close()
b, _ := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
return string(b)
}
zones := []map[string]any{
{"id": 12, "name": "adbraun.lol.", "view": "external", "soa": map[string]any{"serial": 2026100901}},
{"id": 3, "name": "lab.adbraun.lol.", "soa": map[string]any{"serial": 7}},
}
defer func(o string, q bool) { flagOutput, flagQuiet = o, q }(flagOutput, flagQuiet)
for _, tc := range []struct {
format string
quiet bool
v any
want string
}{
{"id", false, zones, "12\n3\n"},
{"id", false, zones[0], "12\n"},
{"field=name,view,soa.serial", false, zones, "adbraun.lol.\texternal\t2026100901\nlab.adbraun.lol.\t\t7\n"},
{"field=soa", false, zones[1], "{\"serial\":7}\n"},
{"table", true, zones, "12\n3\n"},
{"table", true, map[string]any{"status": "ok"}, ""}, // -q without IDs: nothing
} {
flagOutput, flagQuiet = tc.format, tc.quiet
got := capture(func() error { return output(tc.v, func(io.Writer) { t.Error("table printed") }) })
if got != tc.want {
t.Errorf("-o %s (quiet %v) = %q, want %q", tc.format, tc.quiet, got, tc.want)
}
}
}