Files
pulse/internal/api/attention_mutation_scope_test.go
T
rcourtman b161c42e31 Admit attention lifecycle POSTs on the mutation scope
/api/ai/patrol/attention/ is one prefix for reads, lifecycle mutations
(acknowledge, unacknowledge, suppress, unsuppress) and action planning.
Its outer gate used the read-detail scope set for every method, so a
token holding only monitoring:write was refused before the mutation
handler's own monitoring:write check ran. The pinned capability inventory
already says POST .../{item_id}/{mutation} needs monitoring:write.

The outer gate now follows the handler's dispatch: a lifecycle mutation
is admitted by the mutation scope set, everything else by the read set.
Read tokens still cannot mutate, a write-only token still cannot read,
and action planning keeps its read-side gate plus its own authorization.

Contract-Neutral: Behavioural fix that makes the route match its pinned capability inventory; no new API surface or contract.
2026-10-11 16:05:31 +01:00

60 lines
2.5 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// The attention prefix serves reads, lifecycle mutations and action planning.
// The pinned capability inventory says a lifecycle POST needs monitoring:write
// and a read needs monitoring:read, so a token must be admitted by the scope
// that matches what it is doing, not by the read scope for everything.
func TestAttentionRoutesGateEachOperationOnItsOwnScope(t *testing.T) {
const readToken = "attention-scope-read.12345678"
const writeToken = "attention-scope-write.12345678"
cfg := newTestConfigWithTokens(t,
newTokenRecord(t, readToken, []string{config.ScopeMonitoringRead}, nil),
newTokenRecord(t, writeToken, []string{config.ScopeMonitoringWrite}, nil),
)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
do := func(token, method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(`{}`))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
return rec
}
refused := func(rec *httptest.ResponseRecorder) bool {
return rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized
}
const mutation = "/api/ai/patrol/attention/item-1/acknowledge"
if rec := do(writeToken, http.MethodPost, mutation); refused(rec) {
t.Errorf("monitoring:write token POST %s refused with %d: %s", mutation, rec.Code, rec.Body.String())
}
if rec := do(readToken, http.MethodPost, mutation); rec.Code != http.StatusForbidden {
t.Errorf("monitoring:read token POST %s = %d, want 403", mutation, rec.Code)
}
const detail = "/api/ai/patrol/attention/item-1"
if rec := do(readToken, http.MethodGet, detail); refused(rec) {
t.Errorf("monitoring:read token GET %s refused with %d: %s", detail, rec.Code, rec.Body.String())
}
if rec := do(writeToken, http.MethodGet, detail); rec.Code != http.StatusForbidden {
t.Errorf("monitoring:write token GET %s = %d, want 403 (write does not imply read)", detail, rec.Code)
}
// Action planning shares the prefix but is not a lifecycle mutation: it
// keeps the read-side outer gate plus its own authorization, so write
// scope alone must not admit it.
const plan = "/api/ai/patrol/attention/item-1/actions/restart/plan"
if rec := do(writeToken, http.MethodPost, plan); rec.Code != http.StatusForbidden {
t.Errorf("monitoring:write token POST %s = %d, want 403", plan, rec.Code)
}
}