diff --git a/server/internal/api/runscope_test.go b/server/internal/api/runscope_test.go new file mode 100644 index 0000000..1612c13 --- /dev/null +++ b/server/internal/api/runscope_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func runFixture() *models.WorkflowRun { + return &models.WorkflowRun{ + RunID: "r1", + ServerRuns: []models.ServerRun{ + {ServerID: "stg-1", Hostname: "staging-web"}, + {ServerID: "prod-1", Hostname: "prod-db"}, + }, + } +} + +// A run document names every host it touched, hostname included. A restricted +// caller must see only its own, and must be told some entries are missing +// without being told how many — the targets_restricted precedent. +func TestScopeRunHidesOutOfScopeServerRuns(t *testing.T) { + got := scopeRun(runFixture(), map[string]bool{"stg-1": true}, true) + if len(got.ServerRuns) != 1 || got.ServerRuns[0].ServerID != "stg-1" { + t.Fatalf("server_runs = %v, want only stg-1", got.ServerRuns) + } + for _, sr := range got.ServerRuns { + if sr.Hostname == "prod-db" { + t.Error("out-of-scope hostname survived filtering") + } + } + if !got.ServersRestricted { + t.Error("servers_restricted = false with an entry dropped") + } +} + +func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) { + got := scopeRun(runFixture(), nil, false) + if len(got.ServerRuns) != 2 { + t.Fatalf("server_runs = %v, want both", got.ServerRuns) + } + if got.ServersRestricted { + t.Error("unrestricted caller told entries were restricted") + } +} + +// A restricted caller whose scope happens to cover the whole run must not be +// told anything was hidden — the flag is about disclosure, not about being +// restricted in general. +func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) { + got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true) + if got.ServersRestricted { + t.Error("servers_restricted set with nothing dropped") + } +} diff --git a/server/internal/mcp/tools_create_test.go b/server/internal/mcp/tools_create_test.go index b8651ba..43e76d1 100644 --- a/server/internal/mcp/tools_create_test.go +++ b/server/internal/mcp/tools_create_test.go @@ -193,3 +193,36 @@ func TestCreateMonitorDecodesTargetFields(t *testing.T) { t.Errorf("Target.Keyword = %q", m.Target.Keyword) } } + +// A runner is a server ID. buildMonitor must refuse one outright rather than +// dropping it silently, or a model would believe it had pinned a check to an +// agent it never reached. +func TestBuildMonitorRefusesRunner(t *testing.T) { + args := map[string]any{ + "name": "api health", + "type": "http", + "target": map[string]any{"url": "https://example.com"}, + "runner": "some-server-id", + } + if _, err := buildMonitor(args); err == nil { + t.Fatal("buildMonitor accepted a runner argument") + } +} + +// Without a runner it still builds, and never arms itself. +func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) { + m, err := buildMonitor(map[string]any{ + "name": "api health", + "type": "http", + "target": map[string]any{"url": "https://example.com"}, + }) + if err != nil { + t.Fatalf("buildMonitor: %v", err) + } + if m.Runner != "" { + t.Errorf("Runner = %q, want empty so CreateMonitor defaults it to the control plane", m.Runner) + } + if m.Enabled { + t.Error("monitor created enabled") + } +} diff --git a/server/internal/services/tokenscope_test.go b/server/internal/services/tokenscope_test.go index feb4804..7e4316f 100644 --- a/server/internal/services/tokenscope_test.go +++ b/server/internal/services/tokenscope_test.go @@ -111,3 +111,77 @@ func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) { t.Errorf("scoped targets = %v, want only a", got) } } + +// A workflow run is dispatched to whatever ResolveTargetsScoped returns, so +// the property Critical 1 restores is that a run of a production-targeting +// workflow, started by a staging token, reaches nothing. Proved over +// UnionTargets for the same reason as the test above: no database. +func TestScopedRunOfOutOfScopeWorkflowReachesNothing(t *testing.T) { + all := []models.Server{ + {ServerID: "prod-1", Tags: map[string]string{"env": "prod"}}, + {ServerID: "prod-2", Tags: map[string]string{"env": "prod"}}, + } + + scope := map[string]string{"env": "staging"} + visible := []models.Server{} + for _, s := range all { + if ServerInTokenScope(s, scope) { + visible = append(visible, s) + } + } + + // The workflow's own saved targets, both by ID and by tag. + got := UnionTargets(visible, []string{"prod-1", "prod-2"}, map[string]string{"env": "prod"}) + if len(got) != 0 { + t.Errorf("staging-scoped run resolved %v, want nothing", got) + } + // ResolveTargetsScoped turns that empty set into ErrNoTargets, which is + // the same answer a workflow targeting no servers at all gives — so the + // refusal does not tell the caller that production hosts exist. +} + +// The scheduler passes a nil scope because it acts as the system. That must +// keep meaning "the whole fleet", never "nothing", or every scheduled workflow +// would silently stop firing. +func TestNilScopeIsUnrestrictedNotEmpty(t *testing.T) { + all := []models.Server{ + {ServerID: "prod-1", Tags: map[string]string{"env": "prod"}}, + {ServerID: "stg-1", Tags: map[string]string{"env": "staging"}}, + } + visible := []models.Server{} + for _, s := range all { + if ServerInTokenScope(s, nil) { + visible = append(visible, s) + } + } + if len(visible) != 2 { + t.Errorf("nil scope admitted %d servers, want all %d", len(visible), len(all)) + } +} + +// FilterVisibleServerIDs is what narrows a run document's ServerRuns and a +// workflow's target list. The property that matters is that the caller is told +// something was hidden without being told how much. +func TestFilterVisibleServerIDsReportsHiddenWithoutCount(t *testing.T) { + visible := map[string]bool{"a": true} + + got, hidden := FilterVisibleServerIDs([]string{"a", "b", "c"}, visible, true) + if len(got) != 1 || got[0] != "a" { + t.Errorf("filtered = %v, want [a]", got) + } + if !hidden { + t.Error("hidden = false with two ids dropped") + } + + // Dropping one and dropping ten are indistinguishable: hidden is a bool. + _, hiddenOne := FilterVisibleServerIDs([]string{"a", "b"}, visible, true) + if hiddenOne != hidden { + t.Error("hidden distinguishes how many were dropped") + } + + // An unrestricted caller is never told anything was hidden. + got, hidden = FilterVisibleServerIDs([]string{"a", "b"}, nil, false) + if len(got) != 2 || hidden { + t.Errorf("unrestricted filter = %v, %v; want everything and no hidden flag", got, hidden) + } +}