package auth import ( "context" "encoding/json" "errors" "path/filepath" "strings" "testing" "github.com/kami/maven/internal/ipc" ) func TestMaxLayer_SurfaceCapsAuthority(t *testing.T) { // The invariant: surface caps maximum authority. A surface structurally // unable to carry passkey-user-verification caps below L3 — voice never // reaches step-up, telegram never reaches step-up, pc_client + authed_page // + the daemon's own process DO. cases := []struct { surface Surface max Layer }{ {SurfaceVoice, Layer0}, {SurfaceTelegram, Layer2}, {SurfacePCClient, Layer3}, {SurfaceAuthedPage, Layer3}, {SurfaceCoreProcess, Layer3}, {SurfaceUnknown, -1}, } for _, c := range cases { got := MaxLayer(c.surface) if got != c.max { t.Errorf("MaxLayer(%s) = %d; want %d (surface caps authority)", c.surface, got, c.max) } } } func TestSourceAllowed(t *testing.T) { cases := []struct { scope []string src string want bool }{ {[]string{"*"}, "anything", true}, {[]string{"poll:healthcheck"}, "poll:healthcheck", true}, {[]string{"poll:healthcheck"}, "poll:uptime", false}, // compromised poller can't forge a trigger {[]string{"poll:healthcheck", "tap:water"}, "tap:water", true}, {[]string{"poll:healthcheck", "tap:water"}, "ambient", false}, {[]string{}, "anything", false}, // fail closed {nil, "anything", false}, } for _, c := range cases { if got := SourceAllowed(c.scope, c.src); got != c.want { t.Errorf("SourceAllowed(%v, %q) = %v; want %v", c.scope, c.src, got, c.want) } } } func TestRequirement_Table(t *testing.T) { // WriteFact is AuthWrite (carries source-scope); EnableTool is AuthStepUp // (registration-enable moves the boundary); all others are AuthRead. if got := Requirement(ipc.MethodWriteFact); got != AuthWrite { t.Errorf("WriteFact authority = %v; want AuthWrite", got) } if got := Requirement(ipc.MethodEnableTool); got != AuthStepUp { t.Errorf("EnableTool authority = %v; want AuthStepUp", got) } reads := []ipc.Method{ ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince, ipc.MethodPresence, ipc.MethodRecentOutcomes, ipc.MethodCreateReminder, ipc.MethodMarkReminder, ipc.MethodRecordNudge, ipc.MethodResolveNudge, ipc.MethodChat, } for _, m := range reads { if got := Requirement(m); got != AuthRead { t.Errorf("%s authority = %v; want AuthRead", m, got) } } } func TestGate_EnableTool_StepUp(t *testing.T) { // EnableTool is AuthStepUp. At the floor (FloorEnrollment L3 + FloorSession // asserting L3), the local caller may enable — the authed mavweb /tools page // hits this path. With a nil Session (no step-up asserted), it's refused — // step-up can't be granted from what wasn't demonstrated. ctx := context.Background() withSession := &Gate{Enrollment: NewFloorEnrollment(), Session: FloorSession{}} if err := withSession.Check(ctx, ipc.MethodEnableTool, nil); err != nil { t.Errorf("EnableTool with FloorSession = %v; want nil (floor permits)", err) } noSession := &Gate{Enrollment: NewFloorEnrollment()} if err := noSession.Check(ctx, ipc.MethodEnableTool, nil); !errors.Is(err, ipc.ErrForbidden) { t.Errorf("EnableTool with nil Session = %v; want ErrForbidden (fail closed)", err) } } func TestCan_Unenrolled_FailClosed(t *testing.T) { // No Module / unknown surface ⇒ refused, NOT "0-level authed". This is // the surface-caps property applied before the layer caps. if err := Can(ipc.MethodPresence, Scope{}, nil); !errors.Is(err, ErrUnenrolled) { t.Errorf("Can with empty Scope = %v; want ErrUnenrolled", err) } if err := Can(ipc.MethodPresence, Scope{Module: "x", Surface: SurfaceUnknown}, nil); !errors.Is(err, ErrUnenrolled) { t.Errorf("Can with unknown surface = %v; want ErrUnenrolled", err) } } func TestCan_WriteFact_SourceScope(t *testing.T) { // The spec's compromised-poller case: a poller enrolled to write // poll:healthcheck can't forge poll:uptime (or anything else). poller := Scope{ Surface: SurfaceCoreProcess, Module: "poller:healthcheck", SourceScope: []string{"poll:healthcheck"}, } core := Scope{ Surface: SurfaceCoreProcess, Module: "core", SourceScope: []string{"*"}, } // In-scope write succeeds. if err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:healthcheck")); err != nil { t.Errorf("poller writing poll:healthcheck = %v; want nil", err) } // Out-of-scope write is forbidden — auth.ErrForbidden in the chain. err := Can(ipc.MethodWriteFact, poller, mustWriteFactParams("poll:uptime")) if err == nil || !errors.Is(err, ErrForbidden) { t.Errorf("poller writing poll:uptime = %v; want ErrForbidden in chain", err) } if err != nil && !strings.Contains(err.Error(), "poll:uptime") { t.Errorf("forbidden err should name the offending source: got %q", err) } // Core (wildcard) writes anything. if err := Can(ipc.MethodWriteFact, core, mustWriteFactParams("poll:uptime")); err != nil { t.Errorf("core writing poll:uptime = %v; want nil (wildcard scope)", err) } // Empty SourceScope ⇒ fail closed even for an enrolled module. empty := Scope{Surface: SurfaceCoreProcess, Module: "x", SourceScope: nil} if err := Can(ipc.MethodWriteFact, empty, mustWriteFactParams("poll:healthcheck")); !errors.Is(err, ErrForbidden) { t.Errorf("empty SourceScope WriteFact = %v; want ErrForbidden", err) } } func TestCan_Reads_AnyEnrolledModule(t *testing.T) { // Reads permit any enrolled module (the enrollment already gated caller // identity). Reads on SurfaceVoice are OK too — enrollment may have // enrolled a voice module for read-only purposes (e.g. ambient parse). for _, surf := range []Surface{SurfaceVoice, SurfaceTelegram, SurfacePCClient, SurfaceCoreProcess} { scope := Scope{Surface: surf, Module: "x", SourceScope: []string{"*"}} if err := Can(ipc.MethodPresence, scope, nil); err != nil { t.Errorf("read on %s = %v; want nil (any enrolled module may read)", surf, err) } } } // --- Gate --- func TestGate_FloorEnrollment_PreservesPreAuth(t *testing.T) { // The floor must be a no-op vs pre-auth: same-uid trusted, full source // scope, every read and in-scope write passes. Tied end-to-end through // Gate.Check on the ipc.Method enumeration, so AuthRead / AuthWrite both // flow through the composition. g := &Gate{Enrollment: NewFloorEnrollment()} ctx := context.Background() for _, m := range []ipc.Method{ ipc.MethodPresence, ipc.MethodSince, ipc.MethodRecentOutcomes, ipc.MethodCreateReminder, ipc.MethodRecordNudge, ipc.MethodChat, } { if err := g.Check(ctx, m, nil); err != nil { t.Errorf("floor gate Check(%s) = %v; want nil (pre-auth preserved)", m, err) } } if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")); err != nil { t.Errorf("floor gate Check(WriteFact, *) = %v; want nil (full source scope)", err) } } func TestGate_StaticEnrollment_SourceScopeEnforced(t *testing.T) { // A poller enrolled to write only poll:healthcheck is denied a write to // poll:uptime. Gate.Authoritative on real source-scope. Wire code should // be forbidden once we wire through the ipc layer (next test exercises // that path through a real socket). pollerUid := int32(70001) enrollment := &StaticEnrollment{ ByUid: map[int32]Scope{ pollerUid: { Surface: SurfaceCoreProcess, Module: "poll:healthcheck", SourceScope: []string{"poll:healthcheck"}, }, }, Default: &Scope{ // any other uid gets full trust — for tests, doesn't matter Surface: SurfaceCoreProcess, Module: "core", SourceScope: []string{"*"}, }, } g := &Gate{Enrollment: enrollment} ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1234}) if err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil { t.Errorf("poller in-scope write = %v; want nil", err) } err := g.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")) if err == nil { t.Fatalf("poller out-of-scope write returned nil; want forbidden") } if !errors.Is(err, ipc.ErrForbidden) { t.Errorf("poller out-of-scope err chain = %v; want ipc.ErrForbidden in chain (wire code wraps ipc.ErrForbidden)", err) } if !errors.Is(err, ErrForbidden) { t.Errorf("poller out-of-scope err chain = %v; want auth.ErrForbidden in chain (auth tests can satisfy Is)", err) } } func TestGate_Unenrolled_FailsClosed(t *testing.T) { enrollment := &StaticEnrollment{} // no Default ⇒ ErrUnenrolled for any caller g := &Gate{Enrollment: enrollment} ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: 4242, Pid: 99}) err := g.Check(ctx, ipc.MethodPresence, nil) if err == nil { t.Fatalf("un-enrolled caller returned nil; want forbidden") } if !errors.Is(err, ipc.ErrForbidden) { t.Errorf("unenrolled err = %v; want ipc.ErrForbidden in chain (mapped at wire edge)", err) } } // --- end-to-end through ipc.Server --- // TestGate_IpcServer_WiresCheck end-to-end. Server.Check = g.Check; an // out-of-scope WriteFact sent through a real socket rehydrates as // ipc.ErrForbidden on the wire. The seam — no CoreAPI change, no module // change — is the whole point: the daemon sets Server.Check at construction. func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) { pollerUid := int32(70001) enrollment := &StaticEnrollment{ ByUid: map[int32]Scope{ pollerUid: { Surface: SurfaceCoreProcess, Module: "poll:healthcheck", SourceScope: []string{"poll:healthcheck"}, }, }, Default: &Scope{ // any other uid ⇒ un-enrolled → forbidden Surface: SurfaceUnknown, Module: "", }, } gate := &Gate{Enrollment: enrollment} dir := t.TempDir() sock := filepath.Join(dir, "maven.sock") // A fake CoreAPI that records writes; the auth verdict should fire before // it ever gets called. fake := &recordingAPI{} srv, err := ipc.Listen(sock, fake) if err != nil { t.Fatalf("listen: %v", err) } srv.Check = gate.Check done := make(chan struct{}) go func() { _ = srv.Serve() close(done) }() t.Cleanup(func() { _ = srv.Close() <-done }) // Direct in-process check: we can caller-stamp our test ctx with any // uid/pid we want. The socket path produces our real uid (via // SO_PEERCRED), but the auth layer's verdict depends only on the // caller-shape the wire delivered, not the wire transport — so the // in-process check uses the same Gate.Check the socketed dispatch calls. ctx := ipc.WithCaller(context.Background(), ipc.Caller{Uid: pollerUid, Pid: 1}) if err := gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:healthcheck")); err != nil { t.Errorf("in-scope write through gate = %v; want nil", err) } err = gate.Check(ctx, ipc.MethodWriteFact, mustWriteFactParams("poll:uptime")) if !errors.Is(err, ipc.ErrForbidden) { t.Errorf("out-of-scope write through gate = %v; want ipc.ErrForbidden", err) } // Smoke the actual wire path: our real uid is un-enrolled per the // StaticEnrollment (only 70001 is enrolled), so the very next call we // make over the socket is forbidden at the wire — rehydrating on the // client side as ipc.ErrForbidden. This is the full chain: // dispatch → Check (auth) → codeOf → wire → hydrate. cli, err := ipc.Dial(sock) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { _ = cli.Close() }) _, err = cli.WriteFact(context.Background(), ipc.WriteFactReq{ Kind: "env", Key: "service_down", Value: "down", Source: "poll:healthcheck", Confidence: 1.0, }) if !errors.Is(err, ipc.ErrForbidden) { t.Errorf("wire: write from real uid (unenrolled per StaticEnrollment) = %v; want ipc.ErrForbidden", err) } if fake.writes != 0 { t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes) } _, err = cli.Chat(context.Background(), "привет") if !errors.Is(err, ipc.ErrForbidden) { t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err) } if fake.chats != 0 { t.Errorf("auth refused chat but CoreAPI.Chat was called %d time(s)", fake.chats) } } func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) { gate := &Gate{Enrollment: NewFloorEnrollment()} fake := &recordingAPI{} sock := filepath.Join(t.TempDir(), "maven.sock") srv, err := ipc.Listen(sock, fake) if err != nil { t.Fatalf("listen: %v", err) } srv.Check = gate.Check done := make(chan struct{}) go func() { _ = srv.Serve() close(done) }() t.Cleanup(func() { _ = srv.Close() <-done }) cli, err := ipc.Dial(sock) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { _ = cli.Close() }) reply, err := cli.Chat(context.Background(), "привет") if err != nil { t.Fatalf("Chat: %v", err) } if reply != "echo: привет" { t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет") } if fake.chats != 1 { t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats) } } // recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth // check must reject before reaching it, otherwise the refusal leaks into the // fake's counts and we fail. Embeds ipc.UnimplementedCoreAPI so every method // this test doesn't exercise returns ipc.ErrNotImplemented loudly instead of // being hand-stubbed to a canned value nobody checks. type recordingAPI struct { ipc.UnimplementedCoreAPI writes int chats int } func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, error) { r.writes++ return int64(r.writes), nil } func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) { r.chats++ return "echo: " + text, nil } // mustWriteFactParams — minimal WriteFactReq JSON with only the source field, // matching what ipc.dispatch hands to Server.Check (the raw params frame). func mustWriteFactParams(source string) []byte { b, err := json.Marshal(ipc.WriteFactReq{ Kind: "env", Key: "service_down", Value: "down", Source: source, Confidence: 1.0, }) if err != nil { panic(err) } return b }