package engine import ( "slices" "strings" "testing" "jarl/internal/auth" "jarl/internal/model" "jarl/internal/validate" ) // linkedEnv has views internal and external and zone example.com in // internal with a few records. func linkedEnv(t *testing.T) *testEnv { env := newEnv(t) env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}})) env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}), NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}})) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")})) env.submit(env.admin, recIn("internal", "www", "A", "192.0.2.10"), recIn("internal", "mail", "A", "192.0.2.25"), recIn("internal", "@", "MX", "10 mail")) return env } func recIn(view, name, typ, data string) Request { return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data}) } // view returns a view's records of example.com as "name type data S|L", // S for shared, L for local. func (env *testEnv) view(name string) []string { env.t.Helper() s := env.state() v := s.ViewByName(name) z := s.FindZone("example.com.", v.ID) var out []string for _, r := range s.ZoneRecords(z.ID) { if r.Type == "NS" { continue } mark := "L" if r.SharedID != 0 { mark = "S" } if r.Disabled { mark += " disabled" } out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark) } return out } func (env *testEnv) recordID(view, name, typ string) int64 { env.t.Helper() s := env.state() z := s.FindZone("example.com.", s.ViewByName(view).ID) for _, r := range s.ZoneRecords(z.ID) { if r.Name == name+".example.com." && r.Type == typ { return r.ID } } env.t.Fatalf("no %s %s in %s", name, typ, view) return 0 } func want(t *testing.T, what string, got []string, w ...string) { t.Helper() if !slices.Equal(got, w) { t.Errorf("%s:\n got %q\n want %q", what, got, w) } } func TestLinkedZones(t *testing.T) { env := linkedEnv(t) // A zone created linked copies everything; both are linked and shared. env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"} slices.Sort(all) got := env.view("external") slices.Sort(got) want(t, "linked copy", got, all...) s := env.state() if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup { t.Fatalf("not linked: %+v %+v", zs[0], zs[1]) } // Adding, changing, disabling and deleting a shared record reaches the other view. env.submit(env.admin, recIn("external", "ftp", "CNAME", "www")) if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") { t.Errorf("shared add didn't reach internal: %q", env.view("internal")) } env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")})) if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") { t.Errorf("shared change didn't reach external: %q", env.view("external")) } env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")})) if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") { t.Errorf("disable didn't reach internal: %q", env.view("internal")) } env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")})) for _, v := range []string{"internal", "external"} { for _, r := range env.view(v) { if strings.HasPrefix(r, "ftp ") { t.Errorf("delete by match left %s in %s", r, v) } } } // A local override: www in internal points elsewhere; external keeps // the shared one. res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)})) if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") { t.Errorf("override: internal %q", env.view("internal")) } if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") { t.Errorf("override changed external: %q", env.view("external")) } _ = res // A new record of an overridden set joins it as local. env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11")) if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 { t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external")) } // Changing the shared www in external doesn't touch internal's override. env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}})) if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") { t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external")) } // Removing the override brings the shared records back. env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}})) if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") { t.Errorf("override removed: internal %q", env.view("internal")) } // Make a shared set local, then share another view's version. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)})) env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")})) if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") { t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external")) } env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)})) if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") { t.Errorf("share external's version: internal %q", env.view("internal")) } if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") { t.Errorf("validation: %+v", probs) } // Undoing a changeset undoes it in both views. r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")})) cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin) if err != nil { t.Fatal(err) } if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil { t.Fatal(err) } for _, v := range []string{"internal", "external"} { if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") { t.Errorf("revert in %s: %q", v, env.view(v)) } } // Unlinking keeps every record, all local. env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"})) for _, z := range env.state().ZonesNamed("example.com.") { if z.LinkGroup != 0 { t.Errorf("still linked: %s", zoneDesc(env.state(), z)) } } for _, v := range []string{"internal", "external"} { for _, r := range env.view(v) { if strings.HasSuffix(r, " S") { t.Errorf("still shared after unlink in %s: %s", v, r) } } } env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5")) if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") { t.Error("an unlinked zone was changed") } // Linking again works out what is shared: same in both, or not. env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"})) in, ex := env.view("internal"), env.view("external") if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") { t.Errorf("relink: same records not shared: internal %q external %q", in, ex) } if !slices.Contains(in, "solo A 10.0.0.5 L") { t.Errorf("relink: internal-only record should be local: %q", in) } if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") { t.Errorf("validation after relink: %+v", probs) } // Deleting one zone of two dissolves the link. z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID) env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil)) if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 { t.Errorf("after deleting external: %+v", zs) } for _, r := range env.view("internal") { if strings.HasSuffix(r, " S") { t.Errorf("shared record left in an unlinked zone: %s", r) } } } func hasCode(probs []validate.Problem, code string) bool { for _, p := range probs { if p.Code == code { return true } } return false } func TestLinkedPermissions(t *testing.T) { env := linkedEnv(t) env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) u := &model.User{Username: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}} if err := env.st.CreateUser(env.ctx, u); err != nil { t.Fatal(err) } lan := &auth.Principal{User: u, Method: "token"} // A shared change would reach external, which this user may not change. e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")})) if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") { t.Errorf("shared change: %v", e) } // A local override is theirs to make. env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)})) if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") { t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external")) } // Records of a zone that isn't linked can't be "local". e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"})) if e.Kind != KindNotFound { t.Errorf("link_with a view without the zone: %v", e) } } func TestLinkExisting(t *testing.T) { env := linkedEnv(t) // external was made by hand: the same apart from www. env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")})) env.submit(env.admin, recIn("external", "www", "A", "203.0.113.10"), recIn("external", "mail", "A", "192.0.2.25"), recIn("external", "@", "MX", "10 mail")) before := []string{} for _, v := range []string{"internal", "external"} { before = append(before, strings.Join(env.view(v), ";")) } res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}})) in, ex := env.view("internal"), env.view("external") if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") { t.Errorf("different www should stay local: internal %q external %q", in, ex) } if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") { t.Errorf("same mail should be shared: internal %q external %q", in, ex) } if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") { t.Errorf("warnings: %q", res.Warnings) } // Linking never changes what named answers: the same data as before. for i, v := range []string{"internal", "external"} { var data []string for _, r := range env.view(v) { data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L")) } var was []string for _, r := range strings.Split(before[i], ";") { was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L")) } want(t, "answers in "+v, data, was...) } // Sharing internal's www replaces external's. env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)})) if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") { t.Errorf("shared from internal: external %q", env.view("external")) } }