Files
MeshTender/internal/core/console_resolve_test.go
2026-08-09 09:33:47 -04:00

227 lines
7.3 KiB
Go

package core
import (
"fmt"
"regexp"
"strings"
"testing"
"github.com/MeshTender/MeshTender/internal/store"
)
// cmd builds a catalog entry; resolveCommand only reads Template and Arity, and
// Key is carried for readable assertions.
func cmd(key, template string, arity int) *store.Command {
return &store.Command{Key: key, Template: template, Arity: arity}
}
// resolveTestCatalog mirrors the real catalog's tricky cases: overloaded tokens
// (setperm, region put), prefix-related tokens (region vs region get/put),
// dotted sub-keys (set flood.max vs set flood.max.advert), comma-arg commands
// (set radio = one token), and variadic free-text (set name).
func resolveTestCatalog() []*store.Command {
return []*store.Command{
cmd("ver", "ver", 0),
cmd("advert", "advert", 0),
cmd("advert.zerohop", "advert.zerohop", 0),
cmd("erase", "erase", 0),
cmd("setperm.set", "setperm <pubkey> <level>", 2),
cmd("setperm.remove", "setperm <pubkey>", 1),
cmd("set.tx", "set tx <0-22>", 1),
cmd("set.radio", "set radio <f,bw,sf,cr>", 1),
cmd("set.name", "set name <text>", arityVariadic),
cmd("set.flood_max", "set flood.max <n>", 1),
cmd("set.flood_max_advert", "set flood.max.advert <n>", 1),
cmd("region", "region", 0),
cmd("region.get", "region get <name>", 1),
cmd("region.put_root", "region put <name>", 1),
cmd("region.put_sub", "region put <name> <parent>", 2),
cmd("region.def", "region def <tokens>", arityVariadic),
cmd("region.home_get", "region home", 0),
cmd("region.home_set", "region home <name>", 1),
}
}
func TestResolveCommand(t *testing.T) {
t.Parallel()
cat := resolveTestCatalog()
cases := []struct {
typed, want string // want == "" means must be denied (nil)
}{
// Exact no-arg commands; extra tokens must NOT be swallowed.
{"ver", "ver"},
{"ver extra", ""},
{"advert", "advert"},
{"advert.zerohop", "advert.zerohop"},
{"advert evil", ""}, // no args allowed on a 0-arity command
{"erase", "erase"},
// setperm overloaded by arity: 2 = set, 1 = remove, anything else denied.
{"setperm KEY 3", "setperm.set"},
{"setperm KEY", "setperm.remove"},
{"setperm KEY 3 junk", ""},
{"setperm", ""},
// Single-arg vs missing/extra args.
{"set tx 20", "set.tx"},
{"set tx", ""},
{"set tx 20 30", ""},
// Comma-arg radio is ONE whitespace token (arity 1); space form is denied
// (the firmware parses it comma-separated, so the space form is invalid).
{"set radio 868,250,11,5", "set.radio"},
{"set radio 868 250 11 5", ""},
// Variadic free-text takes any number of trailing tokens.
{"set name My Cool Repeater", "set.name"},
{"set name", "set.name"},
// Dotted sub-keys are distinct words — no prefix collision.
{"set flood.max 5", "set.flood_max"},
{"set flood.max.advert 5", "set.flood_max_advert"},
// region: bare is a 0-arg read; "region <unknown>" must be DENIED rather
// than falling through to the bare read (the catch-all bug this prevents).
{"region", "region"},
{"region foo", ""},
{"region get EU", "region.get"},
{"region get", ""},
// Longest token wins: "region put" over "region"; arity picks root vs sub.
{"region put EU", "region.put_root"},
{"region put EU NA", "region.put_sub"},
{"region put EU NA X", ""},
{"region def A B C", "region.def"},
// Optional-arg get/set split by arity.
{"region home", "region.home_get"},
{"region home EU", "region.home_set"},
// Whitespace is normalized.
{"setperm KEY 3", "setperm.set"},
{" set tx 20 ", "set.tx"},
// Total nonsense.
{"totally bogus", ""},
{"", ""},
}
for _, tc := range cases {
got := resolveCommand(tc.typed, cat)
gotKey := ""
if got != nil {
gotKey = got.Key
}
if gotKey != tc.want {
t.Errorf("resolveCommand(%q) = %q, want %q", tc.typed, gotKey, tc.want)
}
}
}
func TestValidCommandText(t *testing.T) {
t.Parallel()
ok := []string{"ver", "set tx 20", "set name My Repeater", "setperm abc 3"}
for _, s := range ok {
if !validCommandText(s) {
t.Errorf("validCommandText(%q) = false, want true", s)
}
}
bad := []string{
"",
" ",
"ver\nreboot", // newline (chaining attempt)
"ver\rreboot", // carriage return
"set name a\x00b", // null
"cmd\x7f", // delete
strings.Repeat("a", maxCommandLen+1), // too long
}
for _, s := range bad {
if validCommandText(s) {
t.Errorf("validCommandText(%q) = true, want false", s)
}
}
}
// TestCommandFeatureCoverage ensures every catalog command carries a feature and
// a valid operation, and that its feature is listed in featureOrder so it renders
// in a known position (a feature missing from featureOrder still shows, but at
// the end — this catches the Go list drifting from the DB). Requires the *_test
// database. The DB also enforces feature<>” and operation IN (...) via CHECK.
func TestCommandFeatureCoverage(t *testing.T) {
t.Parallel()
cat := loadRealCatalog(t)
validOp := map[string]bool{"read": true, "write": true, "delete": true, "action": true}
for _, c := range cat {
if c.Feature == "" {
t.Errorf("command %q has no feature", c.Key)
} else if featureRank(c.Feature) == len(featureOrder) {
t.Errorf("command %q feature %q is not in featureOrder", c.Key, c.Feature)
}
if !validOp[c.Operation] {
t.Errorf("command %q has invalid operation %q", c.Key, c.Operation)
}
}
}
// TestResolveCommandRealCatalog enforces, against the actual seeded catalog, the
// two invariants the console parser's safety depends on:
// 1. No two commands share a (token, arity) — resolution is never ambiguous.
// 2. Every fixed-arity command's arity equals its template's "<arg>" count, and
// each command's own template round-trips back to itself through the parser.
//
// loadRealCatalog migrates the *_test database and returns the seeded catalog,
// skipping the test when the DB isn't configured.
func loadRealCatalog(t *testing.T) []*store.Command {
t.Helper()
st, ctx := coreStore(t)
cat, err := st.ListCommands(ctx)
if err != nil {
t.Fatalf("list commands: %v", err)
}
if len(cat) == 0 {
t.Fatal("empty catalog")
}
return cat
}
// Requires the *_test database (it runs migrations).
func TestResolveCommandRealCatalog(t *testing.T) {
t.Parallel()
cat := loadRealCatalog(t)
argGroup := regexp.MustCompile(`<[^>]*>`)
seen := map[string]string{} // (token|arity) -> key
for _, c := range cat {
token := commandPrefix(c.Template)
groups := len(argGroup.FindAllString(c.Template, -1))
// (1) uniqueness of (token, arity)
k := fmt.Sprintf("%q|%d", token, c.Arity)
if other, dup := seen[k]; dup {
t.Errorf("commands %q and %q share token %q + arity %d (ambiguous)", other, c.Key, token, c.Arity)
}
seen[k] = c.Key
// (2) fixed arity must match the template's arg-group count
if c.Arity >= 0 && groups != c.Arity {
t.Errorf("command %q: arity %d but template %q has %d <arg> groups", c.Key, c.Arity, c.Template, groups)
}
// (3) the command's own template round-trips back to itself
n := c.Arity
if n < 0 {
n = 1 // variadic: one sample arg
}
sample := token
for i := 0; i < n; i++ {
sample += " x"
}
got := resolveCommand(sample, cat)
if got == nil || got.Key != c.Key {
gk := "<nil>"
if got != nil {
gk = got.Key
}
t.Errorf("round-trip: %q (for %q) resolved to %q", sample, c.Key, gk)
}
}
}