tool, mavend: cover the tiers end to end (V-449)

This commit is contained in:
2026-08-04 04:50:56 +04:00
parent 0987dabfc4
commit 7db139b83e
2 changed files with 154 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
// one costs a confirm turn, an irreversible one is refused with the reason.
func TestActPathSpeaksTheTiers(t *testing.T) {
h, st, _ := newClarifyHandler(t)
ctx := context.Background()
now := h.now()
for _, tc := range []struct {
name string
cmd []string
destructive bool
}{
{"status", []string{"true"}, false},
{"restart", []string{"true"}, true},
{"wipe", []string{"rm", "-rf"}, true},
} {
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
t.Fatalf("propose %s: %v", tc.name, err)
}
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
t.Fatalf("enable %s: %v", tc.name, err)
}
}
act := func(fn string) string {
return h.actionAct(ctx, router.Decision{
Intent: router.IntentAct,
Utterance: fn,
Slots: router.Slots{Fn: fn, HasFn: true},
})
}
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
t.Errorf("safe act replied %q; want it to have run", reply)
}
if reply := act("restart"); !strings.Contains(reply, "скажи «да»") {
t.Errorf("destructive act replied %q; want a confirm turn", reply)
}
// Clear the confirm the destructive act parked, so what is pending after
// the irreversible one is only what the irreversible one parked.
h.mu.Lock()
h.pending = nil
h.mu.Unlock()
reply := act("wipe")
if strings.Contains(reply, "скажи «да»") {
t.Fatalf("irreversible act asked for a confirm: %q", reply)
}
if !strings.Contains(reply, "не вернуть") {
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
}
// Nothing was parked, so a later "да" cannot pick it up.
h.mu.Lock()
pending := h.pending
h.mu.Unlock()
if pending != nil {
t.Errorf("an irreversible act parked %+v", pending)
}
// And it is still an enabled row — refusing to run it from voice is not
// the same as taking it off the allowlist.
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package tool
import (
"context"
"errors"
"testing"
"github.com/kami/maven/internal/ipc"
)
func TestRiskOfReadsTheRow(t *testing.T) {
cases := []struct {
name string
tool ipc.Tool
want Risk
}{
{"a plain read", ipc.Tool{Cmd: []string{"systemctl", "status"}}, TierSafe},
{"the checkbox", ipc.Tool{Cmd: []string{"systemctl", "restart"}, Destructive: true}, TierDestructive},
{"a wipe", ipc.Tool{Cmd: []string{"rm", "-rf"}}, TierIrreversible},
{"a wipe behind a wrapper", ipc.Tool{Cmd: []string{"sudo", "/bin/rm"}}, TierIrreversible},
{"a prune behind a subcommand", ipc.Tool{Cmd: []string{"docker", "volume", "prune"}}, TierIrreversible},
{"the house", ipc.Tool{Cmd: []string{"smarthome", "light.kitchen", "turn_off"}}, TierDestructive},
{"the house with the box unticked", ipc.Tool{Cmd: []string{"smarthome", "lock.front", "unlock"}}, TierDestructive},
{"an mcp read", ipc.Tool{Cmd: []string{"mcp", "vikunja", "list_tasks"}}, TierSafe},
{"an mcp write", ipc.Tool{Cmd: []string{"mcp", "vikunja", "delete_task"}, Destructive: true}, TierDestructive},
{"a shape nobody wrote yet", ipc.Tool{}, TierDestructive},
}
for _, c := range cases {
if got := RiskOf(c.tool); got != c.want {
t.Errorf("%s: RiskOf = %q; want %q", c.name, got, c.want)
}
}
}
// The default is the confirm turn. A tier this file does not know is not a
// tier that runs freely.
func TestPolicyForDefaultsToConfirming(t *testing.T) {
for _, r := range []Risk{TierDestructive, Risk("whatever-lands-here-next")} {
p := PolicyFor(r)
if !p.Confirm || !p.VoiceMayRun {
t.Errorf("PolicyFor(%q) = %+v; want a confirm turn she may run", r, p)
}
}
if p := PolicyFor(TierSafe); p.Confirm || !p.VoiceMayRun {
t.Errorf("PolicyFor(safe) = %+v; want it to run", p)
}
if p := PolicyFor(TierIrreversible); !p.Confirm || p.VoiceMayRun {
t.Errorf("PolicyFor(irreversible) = %+v; want voice refused", p)
}
}
// An irreversible act is refused whether or not he said "да", because there is
// no second answer that changes what it would do.
func TestExecRefusesIrreversibleEvenConfirmed(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"wipe": {Name: "wipe", Status: "enabled", Cmd: []string{"rm", "-rf"}, Destructive: true},
}}
e := NewExecutor(api, 0)
ran := false
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
for _, confirmed := range []bool{false, true} {
if _, err := e.Exec(context.Background(), "wipe", []string{"/data"}, confirmed); !errors.Is(err, ErrNeedsAuthedSurface) {
t.Errorf("confirmed=%v: %v; want ErrNeedsAuthedSurface", confirmed, err)
}
}
if ran {
t.Fatal("an irreversible act ran from the voice path")
}
}
// A row with no cmd at all is not a shape this file reads, and it must not
// slide through as safe.
func TestExecConfirmsAnUnreadableRow(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"mystery": {Name: "mystery", Status: "enabled"},
}}
e := NewExecutor(api, 0)
if _, err := e.Exec(context.Background(), "mystery", nil, false); !errors.Is(err, ErrNeedsConfirm) {
t.Errorf("%v; want ErrNeedsConfirm", err)
}
}