Files
jarl/internal/engine/disable_test.go
T
adamandClaude Opus 5.5 295c549479 Disable records instead of deleting them
A disabled record stays in Jarl with its ID, TTL and comment but is
left out of the zone; the zone file lists it as a comment. Web UI
toggle, jarl record disable|enable, record.disable|enable operations
and API endpoints. Database schema 5.

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

177 lines
6.9 KiB
Go

package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func disableReq(id int64, ptr bool) Request {
return NewRequest(OpRecordDisable, id, RecordInput{PTR: ptr})
}
func enableReq(id int64, ptr bool) Request {
return NewRequest(OpRecordEnable, id, RecordInput{PTR: ptr})
}
func (env *testEnv) zoneFile(name string) string {
env.t.Helper()
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".zone"))
if err != nil {
env.t.Fatal(err)
}
return string(b)
}
func serialOf(env *testEnv, zone string) uint32 {
return env.state().ZonesNamed(zone)[0].SOA.Serial
}
func TestDisableRecord(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
www := findRec(env.state(), "www.example.com.", "A")
serial := serialOf(env, "example.com")
res := env.submit(env.admin, disableReq(www.ID, false))
if len(res.Ops) != 1 || res.Ops[0].Summary != "disable www.example.com. A 192.0.2.10" {
t.Fatalf("ops = %+v", res.Ops)
}
if r := env.state().Records[www.ID]; r == nil || !r.Disabled {
t.Fatalf("record = %+v", r)
}
zf := env.zoneFile("example.com")
if !strings.Contains(zf, "; disabled: www\t\tIN\tA\t192.0.2.10\n") || strings.Contains(zf, "\nwww\t") {
t.Errorf("zone file:\n%s", zf)
}
if s := serialOf(env, "example.com"); s != serial+1 {
t.Errorf("serial after disable = %d, want %d", s, serial+1)
}
// Editing a disabled record changes nothing that is served.
env.submit(env.admin, NewRequest(OpRecordUpdate, www.ID, RecordInput{Comment: model.Ptr("old web server"), Data: model.Ptr("192.0.2.11")}))
if s := serialOf(env, "example.com"); s != serial+1 {
t.Errorf("serial after editing a disabled record = %d", s)
}
// Disabling it again says so.
if e := env.submitErr(env.admin, disableReq(www.ID, false)); e.Kind != KindConflict || !strings.Contains(e.Message, "already disabled") {
t.Errorf("second disable: %+v", e)
}
env.submit(env.admin, enableReq(www.ID, false))
r := env.state().Records[www.ID]
if r.Disabled || r.Data != "192.0.2.11" || r.Comment != "old web server" {
t.Errorf("enabled record = %+v", r)
}
if zf := env.zoneFile("example.com"); !strings.Contains(zf, "\nwww\t\tIN\tA\t192.0.2.11\t; old web server\n") || strings.Contains(zf, "disabled") {
t.Errorf("zone file after enable:\n%s", zf)
}
}
func TestDisableValidation(t *testing.T) {
env := newEnv(t)
setupZone(env)
// The zone must keep a name server.
ns := findRec(env.state(), "example.com.", "NS")
if e := env.submitErr(env.admin, disableReq(ns.ID, false)); e.Kind != KindValidation {
t.Errorf("disabling the only NS: %+v", e)
}
// A disabled record doesn't count for the CNAME rule...
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
www := findRec(env.state(), "www.example.com.", "A")
env.submit(env.admin, disableReq(www.ID, false))
res := env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
// ...but there's a warning that it can't come back,
if len(res.Problems) != 1 || res.Problems[0].Code != "disabled_conflict" || res.Problems[0].ObjectID != www.ID {
t.Errorf("problems = %+v", res.Problems)
}
// and enabling it is checked like adding it.
if e := env.submitErr(env.admin, enableReq(www.ID, false)); e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
t.Errorf("enabling next to a CNAME: %+v", e)
}
}
func TestDisableByMatchAndPTR(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}))
ptrDisabled := func() bool {
return findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR").Disabled
}
// By match, with the PTR record.
match := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), PTR: true}
res := env.submit(env.admin, NewRequest(OpRecordDisable, 0, match))
if len(res.Ops) != 2 || res.Ops[1].Summary != "disable 20.2.0.192.in-addr.arpa. PTR nas.example.com." {
t.Fatalf("ops = %+v", res.Ops)
}
if !findRec(env.state(), "nas.example.com.", "A").Disabled || !ptrDisabled() {
t.Fatal("A or PTR not disabled")
}
env.submit(env.admin, NewRequest(OpRecordEnable, 0, match))
if findRec(env.state(), "nas.example.com.", "A").Disabled || ptrDisabled() {
t.Fatal("A or PTR not enabled")
}
// Without ptr, the PTR record stays as it is.
a := findRec(env.state(), "nas.example.com.", "A")
env.submit(env.admin, disableReq(a.ID, false))
if ptrDisabled() {
t.Error("PTR disabled without ptr")
}
// Data narrows the match; nothing matching is an error unless missing_ok.
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
one := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.31")}
env.submit(env.admin, NewRequest(OpRecordDisable, 0, one))
if findRecData(env.state(), "api.example.com.", "192.0.2.30").Disabled || !findRecData(env.state(), "api.example.com.", "192.0.2.31").Disabled {
t.Error("data match wrong")
}
none := RecordInput{Zone: "example.com", Name: model.Ptr("nope"), Type: model.Ptr("A")}
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Kind != KindNotFound {
t.Errorf("no match: %+v", e)
}
// missing_ok: nothing to do (an error the CLI's --missing-ok accepts).
none.MissingOK = true
if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Message != "nothing to do" {
t.Errorf("missing_ok: %+v", e)
}
}
func TestDisableRRSetAndRevert(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31"))
r30 := findRecData(env.state(), "api.example.com.", "192.0.2.30")
r31 := findRecData(env.state(), "api.example.com.", "192.0.2.31")
env.submit(env.admin, disableReq(r30.ID, false), disableReq(r31.ID, false))
// Setting the RRset enables a disabled record with a given value and
// leaves the other disabled one alone.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.40"}}))
s := env.state()
if r := s.Records[r30.ID]; r == nil || r.Disabled {
t.Errorf(".30 = %+v, want kept and enabled", r)
}
if r := s.Records[r31.ID]; r == nil || !r.Disabled {
t.Errorf(".31 = %+v, want kept and disabled", r)
}
// Reverting a disabling changeset enables the record again.
res := env.submit(env.admin, disableReq(r30.ID, false))
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
t.Fatal(err)
}
if env.state().Records[r30.ID].Disabled {
t.Error("revert did not enable the record")
}
}