Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/localapi/trigger_test.go
Fimeg e8b7742422 feat: standalone local authority — helper mint mode + local approval flow
FEAT-003 core (design: RAF/security/06-standalone-authority.md, approved
2026-06-10). On a host with no fleet server, the trust boundary preserved is
root-vs-unprivileged: a root-owned 0600 Ed25519 key signs capability tokens
via a new privileged helper invocation; redflag-local membership lets you
request a mint, never perform one.

Helper gains the mint subcommand: validates forward-only ops, mintable-type
allowlist (no agent-self), host agent-id bind, closure shape (64-hex sha256
fail-closed), hard-coded 15-minute gate-evidence freshness with future-dating
rejection, override-reason requirement for vulnerable/unreachable/overridden
verdicts, duplicate request_id dedupe, journal-before-emission. --init-key /
--retire-key manage the authority lifecycle (retire = the fleet-join swap).
Deny taxonomy 22-25. Round-trip test proves a minted token passes the execute
path's own verification and parses as the wire CapabilityToken.

Agent gains POST /v1/actions/approve-update (single-flight, 409/503 mapping):
fleet-mode refusal, dnf/apt dry-run closure resolve + hash pin (no pin, no
mint), best-effort OSV.dev closure check with honest verdicts (unreachable is
never silent-clear), mint via sudo systemd-run mirroring the execute grant,
then the unchanged verify+execute path. Provisioning script sets up the
journal dir (root:redflag-local 2750 setgid), mint request dir, key init, and
the pinned mint sudoers line.
2026-06-10 09:04:06 -04:00

88 lines
2.5 KiB
Go

package localapi
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
t.Helper()
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, TriggerScan: trigger})
}
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/trigger-scan", nil))
return rec
}
func TestTriggerScanAccepted(t *testing.T) {
var gotSource string
handler := triggerHandler(t, func(source string) error {
gotSource = source
return nil
})
rec := postTrigger(t, handler)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if gotSource != "localapi" {
t.Fatalf("source = %q, want localapi", gotSource)
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["accepted"] != true {
t.Fatalf("accepted = %v, want true", resp["accepted"])
}
}
func TestTriggerScanInFlightIsConflict(t *testing.T) {
handler := triggerHandler(t, func(string) error { return ErrScanInFlight })
rec := postTrigger(t, handler)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
}
func TestTriggerScanUnavailableWithoutCallback(t *testing.T) {
handler := triggerHandler(t, nil)
rec := postTrigger(t, handler)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
}
func TestTriggerScanFailureIs500(t *testing.T) {
handler := triggerHandler(t, func(string) error { return errors.New("boom") })
rec := postTrigger(t, handler)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
func TestTriggerScanRejectsGet(t *testing.T) {
handler := triggerHandler(t, func(string) error { return nil })
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/trigger-scan", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
if rec.Header().Get("Allow") != http.MethodPost {
t.Fatalf("Allow = %q, want POST", rec.Header().Get("Allow"))
}
}