Files
housecarl/internal/server/server_test.go
T
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:57:14 +01:00

541 lines
21 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"os"
"strings"
"sync"
"testing"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/render"
"housecarl/internal/store"
)
// fakeFW stands in for nftables: it records the applied states and keeps
// dynamic sets' entries.
type fakeFW struct {
mu sync.Mutex
applied []*model.State
boot *model.State
elements map[string][]render.Element
applyErr error
deleted []string
restored []string
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.applyErr != nil {
return f.applyErr
}
f.applied = append(f.applied, st.Clone())
return nil
}
func (f *fakeFW) last() *model.State {
f.mu.Lock()
defer f.mu.Unlock()
return f.applied[len(f.applied)-1]
}
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl")
}
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil
}
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.elements[set] = append(f.elements[set], render.Element{Addr: addr, Timeout: timeout})
return nil
}
func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
f.mu.Lock()
defer f.mu.Unlock()
keep := f.elements[set][:0]
for _, e := range f.elements[set] {
if e.Addr != addr {
keep = append(keep, e)
}
}
f.elements[set] = keep
return nil
}
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
}
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = append(f.deleted, fam+" "+name)
return nil
}
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
return "table " + fam + " " + name + " {}", nil
}
func (f *fakeFW) ApplyScript(_ context.Context, script string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.restored = append(f.restored, script)
return nil
}
func (f *fakeFW) SaveBoot(st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()
f.boot = st.Clone()
return nil
}
type hostIfaces map[string][]netip.Prefix
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
func (h hostIfaces) Up(string) bool { return true }
type env struct {
t *testing.T
ts *httptest.Server
srv *Server
eng *engine.Engine
fw *fakeFW
token string // the admin's API token
}
func newEnv(t *testing.T) *env {
t.Helper()
ctx := context.Background()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
hash, _ := auth.HashPassword("secret-pass-1")
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
if err := st.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
eng, err := engine.New(ctx, st, nil)
if err != nil {
t.Fatal(err)
}
fw := &fakeFW{elements: map[string][]render.Element{}}
eng.Firewall = fw
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
srv := New(Config{}, eng, fw, ifs, nil, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
}
// call makes a request with a token; it returns the status and decodes
// the body into out (if given).
func (e *env) call(token, method, path string, body any, out any) int {
e.t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if out != nil && len(b) > 0 {
if err := json.Unmarshal(b, out); err != nil {
e.t.Fatalf("%s %s: %v: %s", method, path, err, b)
}
}
return resp.StatusCode
}
func ptr[T any](v T) *T { return &v }
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
func TestLoginSessionAndCSRF(t *testing.T) {
e := newEnv(t)
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar}
post := func(path, body, csrf string) *http.Response {
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if csrf != "" {
req.Header.Set("X-CSRF-Token", csrf)
}
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 {
t.Errorf("wrong password: %d", r.StatusCode)
}
r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "")
var me api.Whoami
json.NewDecoder(r.Body).Decode(&me)
if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin {
t.Fatalf("login: %d %+v", r.StatusCode, me)
}
body := `{"name":"lan","interfaces":["eth1"]}`
if r := post("/zones", body, ""); r.StatusCode != 403 {
t.Errorf("change without CSRF token: %d", r.StatusCode)
}
if r := post("/zones", body, me.CSRFToken); r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Errorf("change with CSRF token: %d %s", r.StatusCode, b)
}
h, _ := e.srv.store.ListHistory(context.Background(), 1, 0)
if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" || h[0].Description != "create zone lan" {
t.Errorf("history %+v", h)
}
}
func TestObjectsFlow(t *testing.T) {
e := newEnv(t)
var stat api.Status
if e.call(e.token, "GET", "/status", nil, &stat); !stat.Unconfigured || stat.Applied || stat.ApplyError != "" {
t.Errorf("empty configuration: %+v", stat)
}
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || !mr.Applied {
t.Fatalf("zone: %d %+v", code, mr)
}
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24", "2a02:8013:7f3d:10::0/64"}}, &mr); code != 201 {
t.Fatalf("set: %d %+v", code, mr)
}
var set model.AddressSet
json.Unmarshal(mr.Object, &set)
if set.Entries[1] != "2a02:8013:7f3d:10::/64" {
t.Errorf("set entries not normalized: %v", set.Entries)
}
// Rules go to the end of their chain.
var ids []int64
for _, svc := range []string{"ssh", "dns", "https"} {
var r model.Rule
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{svc}}}, &mr)
json.Unmarshal(mr.Object, &r)
if r.Action != "accept" {
t.Errorf("default action %q", r.Action)
}
ids = append(ids, r.ID)
}
// Move https first; PATCH changes only the given fields.
if code := e.call(e.token, "POST", "/rules/"+itoa(ids[2])+"/move", api.MoveInput{To: "start"}, nil); code != 200 {
t.Errorf("move: %d", code)
}
if code := e.call(e.token, "PATCH", "/rules/"+itoa(ids[0]), map[string]any{"comment": "admins", "log": true}, &mr); code != 200 {
t.Errorf("patch: %d %+v", code, mr)
}
var rules []model.Rule
e.call(e.token, "GET", "/rules?chain=input", nil, &rules)
if len(rules) != 3 || rules[0].ID != ids[2] || rules[1].Comment != "admins" || !rules[1].Log || rules[1].Sources[0] != "trusted" {
t.Errorf("rules after move and patch: %+v", rules)
}
// Validation: an unknown service, refused with its code.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"gopher"}}}, &eb); code != 422 || eb.Error.Problems[0].Code != "unknown_service" {
t.Errorf("unknown service: %d %+v", code, eb)
}
// Unknown fields are refused.
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "x", "interfaces": []string{"x"}, "colour": "red"}, &eb); code != 400 {
t.Errorf("unknown field: %d", code)
}
// A set in use can't be deleted.
if code := e.call(e.token, "DELETE", "/sets/trusted", nil, &eb); code != 409 || !strings.Contains(eb.Error.Message, "used by input") {
t.Errorf("deleting a used set: %d %+v", code, eb)
}
// Services list includes the built-in ones.
var svcs []model.Service
e.call(e.token, "GET", "/services", nil, &svcs)
found := false
for _, s := range svcs {
found = found || s.Name == "ssh" && s.Builtin
}
if !found {
t.Errorf("no built-in ssh in %+v", svcs)
}
// Settings merge, built-ins one by one.
if code := e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]bool{"mss_clamp": true}, "own_prefixes": []string{"2a02:8013:7f3d::/48"}}, &mr); code != 200 {
t.Fatalf("settings: %d %+v", code, mr)
}
s := e.eng.State().Settings
if !s.Builtins.MSSClamp || !s.Builtins.Established || s.OwnPrefixes[0] != "2a02:8013:7f3d::/48" {
t.Errorf("settings %+v", s)
}
// The firewall got the latest state; the ruleset is there to see.
if len(e.fw.last().Rules) != 3 {
t.Errorf("applied %+v", e.fw.last().Rules)
}
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+"/ruleset", nil)
req.Header.Set("Authorization", "Bearer "+e.token)
resp, _ := http.DefaultClient.Do(req)
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "ip6 saddr @trusted_v6 udp dport 53 counter accept") {
t.Errorf("ruleset:\n%s", b)
}
var st api.Status
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Counts["rule"] != 3 || len(st.OtherTables) != 1 || st.OtherTables[0] != "inet main" {
t.Errorf("status: %d %+v", code, st)
}
var ifs []api.HostInterface
e.call(e.token, "GET", "/interfaces", nil, &ifs)
if len(ifs) != 2 || ifs[0].Name != "eth1" || ifs[0].Zone != "lan" {
t.Errorf("interfaces %+v", ifs)
}
}
func TestProbation(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 300 * time.Millisecond
ctx := context.Background()
// A network change: pending, then rolled back by itself.
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr)
var pr api.Probation
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 1 || pr.Pending[0] != mr.HistoryID || pr.SecondsLeft > 1 {
t.Fatalf("probation %+v", pr)
}
if v, _ := e.srv.store.GetMeta(ctx, probationKey); !strings.Contains(v, itoa(mr.HistoryID)) {
t.Errorf("probation not saved: %q", v)
}
time.Sleep(700 * time.Millisecond)
if len(e.eng.State().Zones) != 0 || len(e.fw.last().Zones) != 0 {
t.Errorf("not rolled back: state %+v applied %+v", e.eng.State().Zones, e.fw.last().Zones)
}
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 0 {
t.Errorf("still pending: %+v", pr)
}
// Confirmed in time: kept, and saved for the boot.
e.srv.probationFor = 5 * time.Second
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
var conf map[string]int
if code := e.call(e.token, "POST", "/confirm", nil, &conf); code != 200 || conf["confirmed"] != 2 {
t.Errorf("confirm: %d %+v", code, conf)
}
if len(e.eng.State().Zones) != 2 || e.fw.boot == nil || len(e.fw.boot.Zones) != 2 {
t.Errorf("after confirm: state %+v boot %+v", e.eng.State().Zones, e.fw.boot)
}
// Through the socket: no probation, saved for the boot at once.
sock := &auth.Principal{User: &model.User{Username: "root", Admin: true}, Method: "socket"}
res, err := e.eng.Apply(ctx, sock, "", engine.Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
st.Settings.ForwardPolicy = model.PolicyAccept
return nil
})
if err != nil {
t.Fatal(err)
}
e.srv.afterChange(ctx, sock, res)
if len(e.srv.probationStatus().Pending) != 0 || e.fw.boot.Settings.ForwardPolicy != model.PolicyAccept {
t.Errorf("socket change: %+v boot %s", e.srv.probationStatus(), e.fw.boot.Settings.ForwardPolicy)
}
// Pending when housecarld stops: rolled back when it starts.
e.srv.probationFor = time.Hour
e.call(e.token, "PATCH", "/zones/lan", map[string]any{"interfaces": []string{"eth1", "eth2"}}, nil)
srv2 := New(Config{}, e.eng, e.fw, nil, nil, nil)
if !srv2.ResumeProbation(ctx) || len(e.eng.State().Zone("lan").Interfaces) != 1 {
t.Errorf("restart rollback: %+v", e.eng.State().Zone("lan"))
}
h, _ := e.srv.store.ListHistory(ctx, 1, 0)
if h[0].Via != "rollback" || h[0].Username != "housecarl" {
t.Errorf("rollback history %+v", h[0])
}
}
func TestDynamicSetElements(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "guests", Dynamic: true, Timeout: 3600}, nil)
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil)
// A token that may only change the guests set (a captive portal).
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "portal", Grants: []string{"sets:write:guests"}}, &tok); code != 201 {
t.Fatalf("token: %d", code)
}
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.5", Timeout: 1800}, nil); code != 201 {
t.Errorf("add: %d", code)
}
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.0/24"}, nil); code != 400 {
t.Errorf("a prefix: %d", code)
}
var els []api.Element
e.call(tok.Secret, "GET", "/sets/guests/elements", nil, &els)
if len(els) != 1 || els[0].Address != "10.0.30.5" || els[0].Timeout != 1800 {
t.Errorf("elements %+v", els)
}
if code := e.call(tok.Secret, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 403 {
t.Errorf("another set: %d", code)
}
if code := e.call(e.token, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 400 {
t.Errorf("a static set: %d", code)
}
if code := e.call(tok.Secret, "PATCH", "/sets/guests", map[string]any{"timeout": 60}, nil); code != 403 {
t.Errorf("changing the set itself: %d", code)
}
if code := e.call(tok.Secret, "DELETE", "/sets/guests/elements/10.0.30.5", nil, nil); code != 204 {
t.Errorf("delete: %d", code)
}
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "set."})
if len(evs) != 2 || evs[0].Action != "set.delete" {
t.Errorf("audit %+v", evs)
}
}
func TestPermissions(t *testing.T) {
e := newEnv(t)
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok); code != 201 || tok.Secret == "" {
t.Fatalf("token: %d %+v", code, tok)
}
if code := e.call(tok.Secret, "GET", "/counters", nil, nil); code != 200 {
t.Errorf("counters with a log:read token: %d", code)
}
if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 {
t.Errorf("rules with a log:read token: %d", code)
}
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") {
t.Errorf("change with a read token: %d %+v", code, eb)
}
if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 {
t.Errorf("users with a restricted token: %d", code)
}
// A restricted token can't make a wider one.
if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 {
t.Errorf("wider token: %d", code)
}
if code := e.call("", "GET", "/status", nil, nil); code != 401 {
t.Errorf("no token: %d", code)
}
if code := e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 {
t.Errorf("bad token: %d", code)
}
// The last administrator stays.
var users []api.User
e.call(e.token, "GET", "/users", nil, &users)
if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 {
t.Errorf("demoting the last admin: %d", code)
}
}
func TestTokenRotation(t *testing.T) {
e := newEnv(t)
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok)
var rot api.Token
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil {
t.Fatalf("rotate: %d %+v", code, rot)
}
if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 {
t.Errorf("rotated token %+v", rot)
}
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("new secret: %d", code)
}
if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("old secret in its grace period: %d", code)
}
var me api.Whoami
e.call(rot.Secret, "GET", "/auth/whoami", nil, &me)
if me.TokenID != tok.ID {
t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID)
}
// Without a grace period the old secret stops at once.
var rot2 api.Token
e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2)
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 {
t.Errorf("old secret without grace: %d", code)
}
if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("newest secret: %d", code)
}
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 {
t.Errorf("a grace over 7 days: %d", code)
}
// A restricted token can't rotate a wider one.
var narrow api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "log"}, &narrow)
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 {
t.Errorf("narrow token rotating a wider one: %d", code)
}
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 {
t.Errorf("a token rotating itself: %d", code)
}
}
func TestImportAndTakeover(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 300 * time.Millisecond
js, _ := os.ReadFile("../importer/testdata/router.json")
txt, _ := os.ReadFile("../importer/testdata/router.txt")
defs, _ := os.ReadFile("../importer/testdata/router.nft")
in := api.ImportInput{JSON: js, Text: string(txt), Defines: string(defs)}
var res api.ImportResult
if code := e.call(e.token, "POST", "/import/nft?dry_run=1", in, &res); code != 200 || !res.DryRun || len(res.State.Forwards) != 5 || len(res.Notes) == 0 {
t.Fatalf("dry run: %d %+v", code, res.MutationResponse)
}
if len(e.eng.State().Rules) != 0 {
t.Error("a dry run changed the configuration")
}
if code := e.call(e.token, "POST", "/import/nft", in, &res); code != 200 || !res.Applied || res.ConfirmBy == nil {
t.Fatalf("import: %d %+v", code, res.MutationResponse)
}
st := e.eng.State()
if len(st.Forwards) != 5 || st.Zone("wan") == nil || st.Forwards[0].ID < 2 {
t.Errorf("imported: %+v", st.Forwards)
}
// IDs are the store's: a new object doesn't collide.
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "extra", Interfaces: []string{"eth9"}}, &mr); code != 201 {
t.Errorf("a zone after the import: %d %+v", code, mr)
}
// A second import needs --replace.
if code := e.call(e.token, "POST", "/import/nft", in, nil); code != 409 {
t.Errorf("import over a configuration: %d", code)
}
e.call(e.token, "POST", "/confirm", nil, nil)
// Takeover: the old table goes, and comes back unless confirmed.
var out map[string]any
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &out); code != 200 || len(e.fw.deleted) != 1 {
t.Fatalf("takeover: %d %+v", code, out)
}
if !strings.Contains(out["message"].(string), "come back") {
t.Errorf("takeover message %q", out["message"])
}
time.Sleep(700 * time.Millisecond)
if len(e.fw.restored) != 1 || e.fw.restored[0] != "table inet main {}" {
t.Errorf("not restored: %+v", e.fw.restored)
}
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet housecarl"}}, nil); code != 400 {
t.Errorf("taking over Housecarl's own table: %d", code)
}
}