Files
housecarl/internal/engine/engine_test.go
T
abrown38andClaude Opus 5.5 9863dd1cdd Quotas: carry what was used when a rule counts differently
model.QuotaCarry says what becomes of a quota's use when its rule
changes how it counts; the firewall's apply follows it, comparing with
the rules in force: per address to a total starts at the addresses' sum,
wider groups add up their addresses, and what can't be carried (a total
to per address, per source to per destination, finer groups) starts
again, with the engine warning quota_restarts on that change. Before,
per source to per destination kept the sources' use as destinations',
and regrouping left old entries behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:57:18 +01:00

222 lines
7.7 KiB
Go

package engine
import (
"context"
"errors"
"strings"
"testing"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/store"
)
func newEngine(t *testing.T) *Engine {
t.Helper()
st, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
e, err := New(context.Background(), st, nil)
if err != nil {
t.Fatal(err)
}
return e
}
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
// fakeFirewall records what it was asked to apply.
type fakeFirewall struct {
applied []*model.State
checkErr error
applyErr error
}
func (f *fakeFirewall) Check(_ context.Context, st *model.State) error { return f.checkErr }
func (f *fakeFirewall) Apply(_ context.Context, st *model.State) error {
if f.applyErr != nil {
return f.applyErr
}
f.applied = append(f.applied, st.Clone())
return nil
}
func addWAN(t *testing.T, e *Engine) int64 {
t.Helper()
var id int64
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
id = newID()
st.Zones = append(st.Zones, &model.Zone{ID: id, Name: "wan", Interfaces: []string{" wan ", "wan", "ppp*"}, External: true})
return nil
})
if err != nil || !res.Applied || res.HistoryID == 0 {
t.Fatalf("apply: %+v %v", res, err)
}
return id
}
func TestApplyNormalizesAndRecords(t *testing.T) {
e := newEngine(t)
fw := &fakeFirewall{}
e.Firewall = fw
id := addWAN(t, e)
z := e.State().ByID(model.KindZone, id).(*model.Zone)
if strings.Join(z.Interfaces, ",") != "wan,ppp*" {
t.Errorf("interfaces not normalized: %q", z.Interfaces)
}
if len(fw.applied) != 1 || len(fw.applied[0].Zones) != 1 {
t.Errorf("firewall got %+v", fw.applied)
}
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
if len(h) != 1 || h[0].Description != "create zone wan" || h[0].Via != "cli" || h[0].Username != "admin" {
t.Errorf("history %+v", h[0])
}
// Nothing changed: nothing recorded or applied.
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
if err != nil || res.Applied || len(res.Changes) != 0 || len(fw.applied) != 1 {
t.Errorf("empty change: %+v %v", res, err)
}
}
func TestValidationAndFirewallRefusals(t *testing.T) {
e := newEngine(t)
fw := &fakeFirewall{}
e.Firewall = fw
addWAN(t, e)
addRule := func(r *model.Rule) (*Result, error) {
return e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
r.ID = newID()
st.Rules = append(st.Rules, r)
return nil
})
}
// Validation: an unknown set.
_, err := addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Sources: []string{"trusted"}}})
if ee, ok := AsError(err); !ok || ee.Kind != KindValidation || ee.Problems[0].Code != "unknown_set" {
t.Errorf("unknown set: %v", err)
}
// nftables refuses: nothing applied or saved.
fw.checkErr = errors.New("syntax error")
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "bogus"})
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall {
t.Errorf("refused ruleset: %v", err)
}
// Applying fails: nothing saved.
fw.checkErr, fw.applyErr = nil, errors.New("netlink: busy")
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Services: []string{"ssh"}}})
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall || !strings.Contains(ee.Message, "nothing was changed") {
t.Errorf("failed apply: %v", err)
}
if len(e.State().Rules) != 0 {
t.Errorf("rules saved after failures: %+v", e.State().Rules)
}
// A dry run is checked, but neither applied nor saved.
fw.applyErr = nil
before := len(fw.applied)
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1, Action: model.ActionDrop})
return nil
})
if err != nil || res.Applied || len(res.Changes) != 1 || len(fw.applied) != before {
t.Errorf("dry run: %+v %v", res, err)
}
}
func TestRevertAppliesThePreviousRuleset(t *testing.T) {
e := newEngine(t)
fw := &fakeFirewall{}
e.Firewall = fw
addWAN(t, e)
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
st.Settings.ForwardPolicy = model.PolicyAccept
return nil
})
if err != nil || !res.Applied {
t.Fatal(err)
}
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err != nil {
t.Fatal(err)
}
last := fw.applied[len(fw.applied)-1]
if last.Settings.ForwardPolicy != model.PolicyDrop || e.State().Settings.ForwardPolicy != model.PolicyDrop {
t.Errorf("after revert: applied %s, state %s", last.Settings.ForwardPolicy, e.State().Settings.ForwardPolicy)
}
// Undoing it twice is refused.
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err == nil {
t.Error("second undo accepted")
}
}
func TestPositionsStayStableUntilCrowded(t *testing.T) {
e := newEngine(t)
for i := 0; i < 3; i++ {
e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: float64(i + 1), Action: model.ActionAccept,
Match: model.Match{Services: []string{"ssh"}}})
return nil
})
}
// Inserting between two rules changes only the new one.
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1.5, Action: model.ActionDrop})
return nil
})
if err != nil || len(res.Changes) != 1 {
t.Fatalf("insert: %+v %v", res, err)
}
var order []string
for _, r := range e.State().Rules {
order = append(order, r.Action)
}
if strings.Join(order, ",") != "accept,drop,accept,accept" {
t.Errorf("order %v", order)
}
}
// TestQuotaRestartsWarns: a change that counts a quota so what it used
// can't carry over warns; one that can (to a total, wider groups) doesn't.
func TestQuotaRestartsWarns(t *testing.T) {
e := newEngine(t)
ctx := context.Background()
var id int64
if _, err := e.Apply(ctx, admin, "", Options{}, func(st *model.State, newID func() int64) error {
id = newID()
st.Rules = append(st.Rules, &model.Rule{ID: id, Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 1 << 30, Over: true, PerSource: true}})
return nil
}); err != nil {
t.Fatal(err)
}
change := func(f func(r *model.Rule)) []string {
res, err := e.Apply(ctx, admin, "", Options{DryRun: true}, func(st *model.State, _ func() int64) error {
f(st.ByID(model.KindRule, id).(*model.Rule))
return nil
})
if err != nil {
t.Fatal(err)
}
var codes []string
for _, w := range res.Warnings {
if w.Code == "quota_restarts" {
codes = append(codes, w.Message)
}
}
return codes
}
if w := change(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }); len(w) != 1 || !strings.Contains(w[0], "per source can't be counted per destination") {
t.Errorf("per source to per destination: %v", w)
}
if w := change(func(r *model.Rule) { r.Quota.PerSource = false }); len(w) != 0 {
t.Errorf("to a total (summed): %v", w)
}
if w := change(func(r *model.Rule) { r.GroupV6 = 56 }); len(w) != 0 {
t.Errorf("wider groups: %v", w)
}
if w := change(func(r *model.Rule) { r.GroupV6 = 128 }); len(w) != 1 {
t.Errorf("finer groups: %v", w)
}
if w := change(func(r *model.Rule) { r.Quota.Bytes = 2 << 30 }); len(w) != 0 {
t.Errorf("another amount: %v", w)
}
}