diff --git a/pkg/extend/zz2_extend_test.go b/pkg/extend/zz2_extend_test.go new file mode 100644 index 0000000..d6c45ea --- /dev/null +++ b/pkg/extend/zz2_extend_test.go @@ -0,0 +1,243 @@ +package extend + +import ( + "context" + "errors" + "strings" + "testing" +) + +// TestRegister_EmptyAppID covers the app_id-required branch. +func TestRegister_EmptyAppID(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + err := r.Register(Extension{Primitive: PreSendMessage, Method: "m"}) + if err == nil || !strings.Contains(err.Error(), "app_id required") { + t.Errorf("err = %v, want app_id required", err) + } +} + +// TestRegister_UnknownFlagType covers the unknown-flag-type branch. +func TestRegister_UnknownFlagType(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + err := r.Register(Extension{ + AppID: "appx", + Primitive: PreSendMessage, + Method: "m", + AddsFlags: []FlagSpec{{Name: "--x", Type: "float"}}, + }) + if err == nil || !strings.Contains(err.Error(), "unknown type") { + t.Errorf("err = %v, want unknown type error", err) + } +} + +// TestRegister_StableOrder covers the sort.SliceStable path: same-order +// entries are returned in registration order. +func TestRegister_StableOrder(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + _ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "m1", Order: 5}) + _ = r.Register(Extension{AppID: "b", Primitive: PreSendMessage, Method: "m2", Order: 5}) + hooks := r.HooksFor(PreSendMessage) + if len(hooks) != 2 || hooks[0].Method != "m1" || hooks[1].Method != "m2" { + t.Errorf("stable order broken: %+v", hooks) + } +} + +// TestAllRegistered_Empty covers the no-hooks-registered branch. +func TestAllRegistered_Empty(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + if got := r.AllRegistered(); len(got) != 0 { + t.Errorf("AllRegistered on empty registry = %v", got) + } +} + +// TestAllRegistered_AcrossMultiplePoints covers the multi-point branch. +func TestAllRegistered_AcrossMultiplePoints(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + _ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "m"}) + _ = r.Register(Extension{AppID: "b", Primitive: PostRecvMessage, Method: "n"}) + got := r.AllRegistered() + if len(got) != 2 { + t.Errorf("AllRegistered = %d entries, want 2", len(got)) + } +} + +// TestRun_CtxCanceledBeforeFirstHook covers the early ctx.Err() branch. +func TestRun_CtxCanceledBeforeFirstHook(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + _ = r.Register(Extension{AppID: "x", Primitive: PreSendMessage, Method: "m"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := r.Run(ctx, PreSendMessage, HookArgs{}) + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } +} + +// TestRun_NilNextRetainsCurrent covers the "if next != nil" else branch. +func TestRun_NilNextRetainsCurrent(t *testing.T) { + t.Parallel() + d := newDispatcher() + r := NewRegistry(d.dispatch) + d.register("mutating", "h", func(a HookArgs) (HookArgs, error) { + a["mutated"] = true + return nil, nil // returns nil; current must be retained + }) + _ = r.Register(Extension{AppID: "mutating", Primitive: PreSendMessage, Method: "h"}) + out, err := r.Run(context.Background(), PreSendMessage, HookArgs{"original": true}) + if err != nil { + t.Fatal(err) + } + if out["original"] != true { + t.Errorf("original args lost when hook returned nil: %+v", out) + } + // The hook mutated the clone — the mutation lands on the cloned + // map but the stamp/meta should still record the call. + meta := GetMeta(out) + if len(meta.TouchedBy) != 1 || meta.TouchedBy[0].AppID != "mutating" { + t.Errorf("stamp missing: %+v", meta.TouchedBy) + } +} + +// TestUnregisterOne_DropsTargetedKeepsRest covers the targeted-remove +// branch where the surviving entry stays in the right position. +func TestUnregisterOne_DropsTargetedKeepsRest(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + _ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "m1", Order: 1}) + _ = r.Register(Extension{AppID: "a", Primitive: PreSendMessage, Method: "m2", Order: 2}) + r.UnregisterOne("a", PreSendMessage, "m1") + got := r.HooksFor(PreSendMessage) + if len(got) != 1 || got[0].Method != "m2" { + t.Errorf("UnregisterOne dropped wrong entry: %+v", got) + } +} + +// TestCloneArgs_NilReturnsEmpty covers the nil-input branch. +func TestCloneArgs_NilReturnsEmpty(t *testing.T) { + t.Parallel() + got := cloneArgs(nil) + if got == nil { + t.Error("cloneArgs(nil) returned nil") + } + if len(got) != 0 { + t.Errorf("cloneArgs(nil) len = %d, want 0", len(got)) + } +} + +// TestCloneArgs_Duplicates covers the populated-map branch. +func TestCloneArgs_Duplicates(t *testing.T) { + t.Parallel() + src := HookArgs{"a": 1, "b": "two"} + dst := cloneArgs(src) + if dst["a"] != 1 || dst["b"] != "two" { + t.Errorf("clone missing keys: %+v", dst) + } + dst["a"] = 99 + if src["a"] != 1 { + t.Errorf("clone aliased the source map") + } +} + +// TestWrap_EmptyCommandErrors covers the empty-cmd guard. +func TestWrap_EmptyCommandErrors(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + _, err := Wrap(context.Background(), r, "", nil, + func(_ context.Context, a HookArgs) (HookArgs, error) { return a, nil }) + if err == nil || !strings.Contains(err.Error(), "empty command name") { + t.Errorf("err = %v, want empty-command error", err) + } +} + +// TestWrap_NilArgsBecomesEmptyMap covers the args==nil normalize. +func TestWrap_NilArgsBecomesEmptyMap(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + var sawNil bool + out, err := Wrap(context.Background(), r, "noop", nil, + func(_ context.Context, a HookArgs) (HookArgs, error) { + if a == nil { + sawNil = true + } + return a, nil + }) + if err != nil { + t.Fatal(err) + } + if sawNil { + t.Error("core saw nil args; Wrap should normalize to empty map") + } + if out == nil { + t.Error("Wrap returned nil out") + } +} + +// TestWrap_CoreReturnsNilUsesTransformed covers the "result == nil" +// fallback branch. +func TestWrap_CoreReturnsNilUsesTransformed(t *testing.T) { + t.Parallel() + r := NewRegistry(newDispatcher().dispatch) + out, err := Wrap(context.Background(), r, "x", HookArgs{"orig": "yes"}, + func(_ context.Context, a HookArgs) (HookArgs, error) { return nil, nil }) + if err != nil { + t.Fatal(err) + } + if out["orig"] != "yes" { + t.Errorf("transformed args lost when core returned nil: %+v", out) + } +} + +// TestGetMeta_BadMarshalFallsBackEmpty exercises the json.Marshal +// failure branch of GetMeta (passing a channel-bearing value). +func TestGetMeta_BadMarshalFallsBackEmpty(t *testing.T) { + t.Parallel() + // A channel cannot be marshaled. GetMeta must catch that and + // return an empty WireMeta rather than panic. + args := HookArgs{metaKey: make(chan int)} + got := GetMeta(args) + if got == nil { + t.Error("GetMeta returned nil") + } + if len(got.TouchedBy) != 0 { + t.Errorf("expected empty meta, got: %+v", got) + } +} + +// TestGetMeta_BadUnmarshalFallsBackEmpty exercises the json.Unmarshal +// failure branch by passing a map whose value types mismatch the +// WireMeta struct. +func TestGetMeta_BadUnmarshalFallsBackEmpty(t *testing.T) { + t.Parallel() + args := HookArgs{metaKey: map[string]any{ + "required": "not-a-slice", + }} + got := GetMeta(args) + if got == nil { + t.Error("GetMeta returned nil") + } +} + +// TestAppendUnique_AllDuplicatesNoOp covers the "have all" branch. +func TestAppendUnique_AllDuplicatesNoOp(t *testing.T) { + t.Parallel() + dst := []string{"a", "b"} + got := appendUnique(dst, "a", "b") + if len(got) != 2 { + t.Errorf("appendUnique with all dups: %v, want len 2", got) + } +} + +// TestAppendUnique_NoExisting covers the empty-dst branch. +func TestAppendUnique_NoExisting(t *testing.T) { + t.Parallel() + got := appendUnique(nil, "a", "b", "a") + if len(got) != 2 { + t.Errorf("appendUnique on nil dst: %v, want 2", got) + } +} diff --git a/pkg/extend/zz_wiremeta_test.go b/pkg/extend/zz_wiremeta_test.go new file mode 100644 index 0000000..dc8f2b1 --- /dev/null +++ b/pkg/extend/zz_wiremeta_test.go @@ -0,0 +1,152 @@ +package extend + +import ( + "testing" +) + +func TestGetMeta_AbsentReturnsEmpty(t *testing.T) { + t.Parallel() + got := GetMeta(HookArgs{}) + if got == nil { + t.Fatal("GetMeta returned nil") + } + if got.Required != nil || len(got.TouchedBy) != 0 { + t.Errorf("not empty: %+v", got) + } +} + +func TestGetMeta_NilValueReturnsEmpty(t *testing.T) { + t.Parallel() + got := GetMeta(HookArgs{"__meta": nil}) + if got == nil || len(got.TouchedBy) != 0 { + t.Errorf("not empty: %+v", got) + } +} + +func TestGetMeta_StarReturnsAsIs(t *testing.T) { + t.Parallel() + m := &WireMeta{Required: []string{"app-1"}} + got := GetMeta(HookArgs{"__meta": m}) + if got != m { + t.Errorf("got %v, want pointer-equal to %v", got, m) + } +} + +func TestGetMeta_MapRoundtrips(t *testing.T) { + t.Parallel() + raw := map[string]any{ + "required": []any{"app-x"}, + } + got := GetMeta(HookArgs{"__meta": raw}) + if len(got.Required) != 1 || got.Required[0] != "app-x" { + t.Errorf("Required = %v", got.Required) + } +} + +func TestSetMeta_NilDeletesKey(t *testing.T) { + t.Parallel() + args := HookArgs{"__meta": &WireMeta{}} + SetMeta(args, nil) + if _, ok := args["__meta"]; ok { + t.Error("expected key to be deleted") + } +} + +func TestSetMeta_StoresPointer(t *testing.T) { + t.Parallel() + args := HookArgs{} + m := &WireMeta{Encoding: []string{"gzip"}} + SetMeta(args, m) + if args["__meta"] != m { + t.Error("SetMeta did not stash pointer") + } +} + +func TestSetDetails_NilDeletes(t *testing.T) { + t.Parallel() + args := HookArgs{"__meta_details": map[string]any{"k": 1}} + SetDetails(args, nil) + if _, ok := args["__meta_details"]; ok { + t.Error("expected details key to be deleted") + } +} + +func TestSetDetails_StoresMap(t *testing.T) { + t.Parallel() + args := HookArgs{} + SetDetails(args, map[string]any{"k": "v"}) + got, _ := args["__meta_details"].(map[string]any) + if got["k"] != "v" { + t.Errorf("details not stored") + } +} + +func TestAddRequired_NoOpOnEmpty(t *testing.T) { + t.Parallel() + args := HookArgs{} + AddRequired(args) + if _, ok := args["__meta_required"]; ok { + t.Error("empty AddRequired should not set key") + } +} + +func TestAddRequired_AppendsAcrossCalls(t *testing.T) { + t.Parallel() + args := HookArgs{} + AddRequired(args, "a", "b") + AddRequired(args, "c") + got, _ := args["__meta_required"].([]string) + if len(got) != 3 { + t.Errorf("len = %d, want 3", len(got)) + } +} + +func TestAddEncoding_NoOpOnEmpty(t *testing.T) { + t.Parallel() + args := HookArgs{} + AddEncoding(args) + if _, ok := args["__meta_encoding"]; ok { + t.Error("empty AddEncoding should not set key") + } +} + +func TestAddEncoding_AppendsInOrder(t *testing.T) { + t.Parallel() + args := HookArgs{} + AddEncoding(args, "gzip") + AddEncoding(args, "br") + got, _ := args["__meta_encoding"].([]string) + if len(got) != 2 || got[0] != "gzip" || got[1] != "br" { + t.Errorf("got %v", got) + } +} + +func TestMissingRequired_AllPresent(t *testing.T) { + t.Parallel() + missing := MissingRequired(&WireMeta{Required: []string{"a", "b"}}, []string{"a", "b", "c"}) + if missing != nil { + t.Errorf("missing = %v, want nil", missing) + } +} + +func TestMissingRequired_SomeMissing(t *testing.T) { + t.Parallel() + missing := MissingRequired(&WireMeta{Required: []string{"a", "b", "c"}}, []string{"a"}) + if len(missing) != 2 { + t.Errorf("missing = %v, want 2", missing) + } +} + +func TestMissingRequired_NilMeta(t *testing.T) { + t.Parallel() + if got := MissingRequired(nil, []string{"a"}); got != nil { + t.Errorf("nil meta: got %v, want nil", got) + } +} + +func TestMissingRequired_EmptyRequired(t *testing.T) { + t.Parallel() + if got := MissingRequired(&WireMeta{}, []string{"a"}); got != nil { + t.Errorf("empty required: got %v, want nil", got) + } +} diff --git a/pkg/ipc/zz2_frame_test.go b/pkg/ipc/zz2_frame_test.go new file mode 100644 index 0000000..5ef1732 --- /dev/null +++ b/pkg/ipc/zz2_frame_test.go @@ -0,0 +1,324 @@ +package ipc + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net" + "strings" + "sync" + "testing" +) + +// shortReader exposes io.EOF on the very first read, simulating a peer +// that closed the connection cleanly between frames. +type eofReader struct{} + +func (eofReader) Read([]byte) (int, error) { return 0, io.EOF } + +// TestReadFrame_EOFOnHeader covers the clean-EOF branch. +func TestReadFrame_EOFOnHeader(t *testing.T) { + t.Parallel() + _, err := ReadFrame(eofReader{}) + if !errors.Is(err, io.EOF) { + t.Errorf("err = %v, want io.EOF", err) + } +} + +// TestReadFrame_PartialHeaderIsUnexpectedEOF covers the io.ReadFull +// partial-read error path on the header. +func TestReadFrame_PartialHeaderIsUnexpectedEOF(t *testing.T) { + t.Parallel() + r := bytes.NewReader([]byte{0x00, 0x00}) // only 2 of 4 header bytes + _, err := ReadFrame(r) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("err = %v, want io.ErrUnexpectedEOF", err) + } +} + +// TestReadFrame_ZeroLengthRejected covers the explicit zero-length +// branch. +func TestReadFrame_ZeroLengthRejected(t *testing.T) { + t.Parallel() + hdr := make([]byte, 4) + binary.BigEndian.PutUint32(hdr, 0) + _, err := ReadFrame(bytes.NewReader(hdr)) + if err == nil || !strings.Contains(err.Error(), "zero-length") { + t.Errorf("err = %v, want zero-length error", err) + } +} + +// TestReadFrame_OversizedRejected covers the MaxFrameSize branch. +func TestReadFrame_OversizedRejected(t *testing.T) { + t.Parallel() + hdr := make([]byte, 4) + binary.BigEndian.PutUint32(hdr, uint32(MaxFrameSize+1)) + _, err := ReadFrame(bytes.NewReader(hdr)) + if !errors.Is(err, ErrFrameTooLarge) { + t.Errorf("err = %v, want ErrFrameTooLarge", err) + } +} + +// TestReadFrame_PartialBodyIsError covers the partial-body branch. +func TestReadFrame_PartialBodyIsError(t *testing.T) { + t.Parallel() + hdr := make([]byte, 4) + binary.BigEndian.PutUint32(hdr, 100) // promise 100 bytes, supply 5 + buf := append(hdr, []byte("short")...) + _, err := ReadFrame(bytes.NewReader(buf)) + if err == nil || !strings.Contains(err.Error(), "read body") { + t.Errorf("err = %v, want read body error", err) + } +} + +// TestReadFrame_BadJSONBody covers the json.Unmarshal branch. +func TestReadFrame_BadJSONBody(t *testing.T) { + t.Parallel() + hdr := make([]byte, 4) + body := []byte("not-json") + binary.BigEndian.PutUint32(hdr, uint32(len(body))) + buf := append(hdr, body...) + _, err := ReadFrame(bytes.NewReader(buf)) + if err == nil || !strings.Contains(err.Error(), "unmarshal") { + t.Errorf("err = %v, want unmarshal error", err) + } +} + +// errWriter fails after a configurable number of bytes. +type errWriter struct { + max int + called int +} + +func (w *errWriter) Write(p []byte) (int, error) { + w.called++ + if w.called > w.max { + return 0, errors.New("write boom") + } + return len(p), nil +} + +// TestWriteFrame_HeaderWriteFails covers the "write length: ..." branch. +func TestWriteFrame_HeaderWriteFails(t *testing.T) { + t.Parallel() + w := &errWriter{max: 0} + env := &Envelope{Type: EnvReq, ReqID: "x", Method: "m"} + err := WriteFrame(w, env) + if err == nil || !strings.Contains(err.Error(), "write length") { + t.Errorf("err = %v, want write length error", err) + } +} + +// TestWriteFrame_BodyWriteFails covers the "write body: ..." branch +// (header write succeeds, body write fails). +func TestWriteFrame_BodyWriteFails(t *testing.T) { + t.Parallel() + w := &errWriter{max: 1} + env := &Envelope{Type: EnvReq, ReqID: "x", Method: "m"} + err := WriteFrame(w, env) + if err == nil || !strings.Contains(err.Error(), "write body") { + t.Errorf("err = %v, want write body error", err) + } +} + +// TestServe_NonReqEnvelopeSkipped wires a server, sends an envelope +// with type=reply, and confirms the server keeps reading instead of +// replying — covers the type-mismatch silent-skip branch in Serve. +func TestServe_NonReqEnvelopeSkipped(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + d := NewDispatcher() + d.Register("echo", func(_ context.Context, req *Envelope) (json.RawMessage, error) { + return req.Payload, nil + }) + + done := make(chan error, 1) + go func() { done <- Serve(context.Background(), s, d) }() + + // Send a reply-shaped envelope. The server skips it silently. + wrong := &Envelope{Type: EnvReply, ReqID: "stale"} + if err := WriteFrame(c, wrong); err != nil { + t.Fatal(err) + } + // Now send a real request to confirm the loop kept running. + var out json.RawMessage + if err := Call(c, "echo", "hi", &out); err != nil { + t.Fatalf("Call: %v", err) + } + _ = c.Close() + <-done +} + +// TestServe_ReturnsCtxError covers the ctx.Err early return path. +func TestServe_ReturnsCtxError(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // canceled before Serve loops + + if err := Serve(ctx, s, NewDispatcher()); err == nil { + t.Error("Serve with canceled ctx should error") + } +} + +// TestServe_ReadErrorReturnsWrapped covers the non-EOF read-error branch. +func TestServe_ReadErrorReturnsWrapped(t *testing.T) { + t.Parallel() + // Half-write a header then close — the server will see ErrUnexpectedEOF. + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + done := make(chan error, 1) + go func() { done <- Serve(context.Background(), s, NewDispatcher()) }() + + // Write a partial header (2 bytes) then close. This won't propagate to + // the server-side ReadFull until the pipe is closed because pipes are + // synchronous; instead, write a full header + truncated body to force + // a "read body" error. + body := []byte(`{"type":"req","req_id":"x"`) // truncated body — looks like header promises N bytes + hdr := make([]byte, 4) + binary.BigEndian.PutUint32(hdr, uint32(len(body)+50)) // promise more than we send + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, _ = c.Write(append(hdr, body...)) + _ = c.Close() + }() + wg.Wait() + + err := <-done + if err == nil { + t.Error("Serve: expected non-nil error on partial body") + } +} + +// TestCall_RandReqIDWorksRepeatedly drives randReqID indirectly by +// issuing many Call invocations — confirms no rand-source exhaustion +// and the 100% branch coverage. +func TestCall_RandReqIDWorksRepeatedly(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + d := NewDispatcher() + d.Register("noop", func(_ context.Context, _ *Envelope) (json.RawMessage, error) { + return nil, nil + }) + go func() { _ = Serve(context.Background(), s, d) }() + + for i := 0; i < 5; i++ { + if err := Call(c, "noop", nil, nil); err != nil { + t.Errorf("call %d: %v", i, err) + } + } +} + +// TestCall_ServerClosesBeforeReply covers the io.EOF → "server closed +// before reply" branch in Call. We swallow the request fully (so the +// write side succeeds), then close — Call's ReadFrame sees clean EOF. +func TestCall_ServerClosesBeforeReply(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + + go func() { + // Drain the full request frame (header + body) so the client's + // WriteFrame can complete before we close. + _, _ = ReadFrame(s) + _ = s.Close() + }() + + err := Call(c, "method", nil, nil) + if err == nil || !strings.Contains(err.Error(), "server closed") { + t.Errorf("err = %v, want 'server closed before reply'", err) + } +} + +// TestCall_ReqIDMismatch covers the req_id mismatch branch. +func TestCall_ReqIDMismatch(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + // Server: read the request, send a reply with a DIFFERENT req_id. + go func() { + req, err := ReadFrame(s) + if err != nil { + return + } + _ = WriteFrame(s, &Envelope{ + Type: EnvReply, + ReqID: "mismatch-" + req.ReqID, // intentionally wrong + }) + }() + err := Call(c, "method", nil, nil) + if err == nil || !strings.Contains(err.Error(), "req_id mismatch") { + t.Errorf("err = %v, want req_id mismatch", err) + } +} + +// TestCall_UnexpectedEnvelopeType covers the "default" branch in the +// reply switch. +func TestCall_UnexpectedEnvelopeType(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + go func() { + req, err := ReadFrame(s) + if err != nil { + return + } + _ = WriteFrame(s, &Envelope{Type: "weird-type", ReqID: req.ReqID}) + }() + err := Call(c, "method", nil, nil) + if err == nil || !strings.Contains(err.Error(), "unexpected envelope type") { + t.Errorf("err = %v, want unexpected envelope type", err) + } +} + +// TestCall_BadResultUnmarshal covers the result-unmarshal error branch. +func TestCall_BadResultUnmarshal(t *testing.T) { + t.Parallel() + c, s := net.Pipe() + defer c.Close() + defer s.Close() + + d := NewDispatcher() + d.Register("send", func(_ context.Context, _ *Envelope) (json.RawMessage, error) { + return json.RawMessage(`"a string"`), nil + }) + go func() { _ = Serve(context.Background(), s, d) }() + + var dst int // intentionally wrong type for a string payload + err := Call(c, "send", nil, &dst) + if err == nil || !strings.Contains(err.Error(), "unmarshal result") { + t.Errorf("err = %v, want unmarshal result error", err) + } +} + +// TestCall_BadArgsMarshal covers the marshal-args error branch. +func TestCall_BadArgsMarshal(t *testing.T) { + t.Parallel() + c, _ := net.Pipe() + defer c.Close() + // json.Marshal of a channel returns an error. + err := Call(c, "method", make(chan int), nil) + if err == nil || !strings.Contains(err.Error(), "marshal args") { + t.Errorf("err = %v, want marshal args error", err) + } +} diff --git a/pkg/ipc/zz_more_test.go b/pkg/ipc/zz_more_test.go new file mode 100644 index 0000000..850b7c3 --- /dev/null +++ b/pkg/ipc/zz_more_test.go @@ -0,0 +1,44 @@ +package ipc + +import ( + "strings" + "testing" +) + +func TestErrServerError_FormatsMessage(t *testing.T) { + t.Parallel() + e := &ErrServerError{Msg: "bad things"} + if !strings.Contains(e.Error(), "bad things") { + t.Errorf("Error() = %q", e.Error()) + } + if !strings.Contains(e.Error(), "server error") { + t.Errorf("Error() = %q, want 'server error' prefix", e.Error()) + } +} + +func TestDispatcher_Methods(t *testing.T) { + t.Parallel() + d := NewDispatcher() + d.Register("a", nil) + d.Register("b", nil) + d.Register("c", nil) + got := d.Methods() + if len(got) != 3 { + t.Errorf("Methods len = %d, want 3", len(got)) + } + // Ensure each is present (map iteration order is random). + want := map[string]bool{"a": true, "b": true, "c": true} + for _, m := range got { + if !want[m] { + t.Errorf("unexpected method %q", m) + } + } +} + +func TestDispatcher_MethodsEmpty(t *testing.T) { + t.Parallel() + d := NewDispatcher() + if got := d.Methods(); len(got) != 0 { + t.Errorf("Methods on empty dispatcher = %v", got) + } +} diff --git a/pkg/manifest/zz2_validate_test.go b/pkg/manifest/zz2_validate_test.go new file mode 100644 index 0000000..44aee2a --- /dev/null +++ b/pkg/manifest/zz2_validate_test.go @@ -0,0 +1,225 @@ +package manifest + +import ( + "strings" + "testing" +) + +// TestParse_BadJSON covers the json.Unmarshal error branch in Parse. +func TestParse_BadJSON(t *testing.T) { + t.Parallel() + _, err := Parse([]byte("not-json")) + if err == nil { + t.Error("Parse on bad JSON should error") + } +} + +// TestParse_EmptyDocument covers the empty-object branch. +func TestParse_EmptyDocument(t *testing.T) { + t.Parallel() + m, err := Parse([]byte("{}")) + if err != nil { + t.Fatalf("Parse({}) = %v", err) + } + if m == nil { + t.Fatal("nil manifest") + } + // Validate on an empty manifest produces errors (all required + // fields missing), exercising several Validate branches. + errs := m.Validate() + if len(errs) == 0 { + t.Error("empty manifest should fail validation") + } +} + +// TestValidate_EmptyBinaryPath covers the binary.path empty branch. +func TestValidate_EmptyBinaryPath(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Binary.Path = " " // whitespace-only + errs := m.Validate() + if !hasErrorContaining(errs, "binary.path") { + t.Errorf("expected binary.path error, got: %v", errs) + } +} + +// TestValidate_EmptyStoreSignature covers the store.signature +// whitespace-only branch. +func TestValidate_EmptyStoreSignature(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Store.Signature = " " + errs := m.Validate() + if !hasErrorContaining(errs, "store.signature") { + t.Errorf("expected store.signature error, got: %v", errs) + } +} + +// TestValidate_BadStorePublisher covers the publisher-pattern branch. +func TestValidate_BadStorePublisher(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Store.Publisher = "not-a-pubkey" + errs := m.Validate() + if !hasErrorContaining(errs, "store.publisher") { + t.Errorf("expected store.publisher error, got: %v", errs) + } +} + +// TestValidate_BadAffiliatePubkey covers the affiliates pubkey branch. +func TestValidate_BadAffiliatePubkey(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Affiliates = []Affiliate{{Pubkey: "bogus", Role: "settlement"}} + errs := m.Validate() + if !hasErrorContaining(errs, "affiliates[0].pubkey") { + t.Errorf("expected affiliate pubkey error, got: %v", errs) + } +} + +// TestValidate_EmptyAffiliateRole covers the affiliate role branch. +func TestValidate_EmptyAffiliateRole(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Affiliates = []Affiliate{{ + Pubkey: "ed25519:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + Role: " ", + }} + errs := m.Validate() + if !hasErrorContaining(errs, "affiliates[0].role") { + t.Errorf("expected affiliate role error, got: %v", errs) + } +} + +// TestValidate_EmptyDependsMethods covers the depends methods branch. +func TestValidate_EmptyDependsMethods(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Depends = []Dependency{{App: "io.other.app", Methods: nil}} + errs := m.Validate() + if !hasErrorContaining(errs, "depends[0].methods") { + t.Errorf("expected depends methods error, got: %v", errs) + } +} + +// TestValidate_EmptyExtendsMethod covers the extends[].method branch. +func TestValidate_EmptyExtendsMethod(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Exposes = append(m.Exposes, "wallet.h") + m.Extends = []Extension{{Primitive: "send-message.pre", Method: " "}} + errs := m.Validate() + if !hasErrorContaining(errs, "extends[0].method") { + t.Errorf("expected extends.method error, got: %v", errs) + } +} + +// TestValidate_GrantConditionInvalidLeaf covers the unknown-kind +// branch in validateCondition. +func TestValidate_GrantConditionInvalidLeaf(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[1].Condition = &Condition{Kind: "made-up-kind"} + errs := m.Validate() + if !hasErrorContaining(errs, "made-up-kind") { + t.Errorf("expected unknown-kind error, got: %v", errs) + } +} + +// TestValidate_GrantConditionEmptyMustError covers the "neither leaf +// nor composite" branch (empty condition). +func TestValidate_GrantConditionEmptyMustError(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[1].Condition = &Condition{} + errs := m.Validate() + if !hasErrorContaining(errs, "must specify either kind or op") { + t.Errorf("expected 'must specify' error, got: %v", errs) + } +} + +// TestValidate_GrantConditionBadOp covers the default-case (unknown +// op) branch. +func TestValidate_GrantConditionBadOp(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[1].Condition = &Condition{ + Op: "xor", Compose: []Condition{{Kind: "rate"}, {Kind: "cap"}}, + } + errs := m.Validate() + if !hasErrorContaining(errs, "and|or|not") { + t.Errorf("expected bad-op error, got: %v", errs) + } +} + +// TestValidate_GrantConditionNotNeedsExactlyOne covers the "not needs +// exactly 1" branch. +func TestValidate_GrantConditionNotNeedsExactlyOne(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[1].Condition = &Condition{ + Op: "not", Compose: []Condition{{Kind: "rate"}, {Kind: "cap"}}, + } + errs := m.Validate() + if !hasErrorContaining(errs, "not needs exactly 1") { + t.Errorf("expected not-arity error, got: %v", errs) + } +} + +// TestValidate_GrantConditionAndNeedsAtLeastTwo covers the and/or +// minimum-arity branch. +func TestValidate_GrantConditionAndNeedsAtLeastTwo(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[1].Condition = &Condition{ + Op: "and", Compose: []Condition{{Kind: "rate"}}, + } + errs := m.Validate() + if !hasErrorContaining(errs, "needs at least 2") { + t.Errorf("expected and-arity error, got: %v", errs) + } +} + +// TestValidate_NestedCompositeWalks covers the recursive walk over +// compose entries. +func TestValidate_NestedCompositeWalks(t *testing.T) { + t.Parallel() + m := mustValid(t) + // Outer 'not' wrapping an inner kind=bad → recursion must surface + // the inner error. + m.Grants[1].Condition = &Condition{ + Op: "not", Compose: []Condition{{Kind: "no-such-kind"}}, + } + errs := m.Validate() + if !hasErrorContaining(errs, "no-such-kind") { + t.Errorf("expected recursion to surface inner error, got: %v", errs) + } +} + +// TestValidate_GrantEmptyTarget covers the grant.target empty branch. +func TestValidate_GrantEmptyTarget(t *testing.T) { + t.Parallel() + m := mustValid(t) + m.Grants[0].Target = " " + errs := m.Validate() + if !hasErrorContaining(errs, "target must not be empty") { + t.Errorf("expected target-empty error, got: %v", errs) + } +} + +// TestMarshal_PreservesStructuralFields confirms a roundtrip produces +// valid JSON containing the input fields. Doubles as smoke for +// Marshal(). +func TestMarshal_PreservesStructuralFields(t *testing.T) { + t.Parallel() + m := mustValid(t) + body, err := m.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + for _, want := range []string{`"io.pilot.wallet"`, `"binary"`, `"grants"`} { + if !strings.Contains(string(body), want) { + t.Errorf("body missing %s: %s", want, body) + } + } +} diff --git a/pkg/manifest/zz_marshal_test.go b/pkg/manifest/zz_marshal_test.go new file mode 100644 index 0000000..c97c4e7 --- /dev/null +++ b/pkg/manifest/zz_marshal_test.go @@ -0,0 +1,24 @@ +package manifest + +import ( + "strings" + "testing" +) + +func TestMarshal_Roundtrip(t *testing.T) { + t.Parallel() + m := &Manifest{ + ID: "io.test.app", + ManifestVersion: 1, + AppVersion: "0.1.0", + Protection: "shareable", + Binary: Binary{Runtime: "go", Path: "bin/x", SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + } + body, err := m.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(body), `"io.test.app"`) { + t.Errorf("body missing app ID: %q", body) + } +} diff --git a/pkg/payment/zz2_seal_test.go b/pkg/payment/zz2_seal_test.go new file mode 100644 index 0000000..d9c4f2a --- /dev/null +++ b/pkg/payment/zz2_seal_test.go @@ -0,0 +1,145 @@ +package payment + +import ( + "bytes" + "strings" + "testing" +) + +// TestSealRegistry_IDs covers the IDs accessor (was 0% coverage). +func TestSealRegistry_IDs(t *testing.T) { + t.Parallel() + r := NewSealRegistry() + ids := r.IDs() + if len(ids) != 1 || ids[0] != DefaultSealID { + t.Errorf("IDs() = %v, want [%s]", ids, DefaultSealID) + } +} + +// TestSealRegistry_IDsAfterRegister covers IDs returning multiple entries. +func TestSealRegistry_IDsAfterRegister(t *testing.T) { + t.Parallel() + r := NewSealRegistry() + _ = r.Register(fakeSeal{id: "fake/v1"}) + ids := r.IDs() + if len(ids) != 2 { + t.Errorf("IDs() len = %d, want 2", len(ids)) + } +} + +// TestSealRegistry_RegisterNil covers the nil-seal branch. +func TestSealRegistry_RegisterNil(t *testing.T) { + t.Parallel() + r := NewSealRegistry() + if err := r.Register(nil); err == nil { + t.Error("Register(nil) should error") + } +} + +// TestSealRegistry_RegisterEmptyID covers the empty-ID branch. +func TestSealRegistry_RegisterEmptyID(t *testing.T) { + t.Parallel() + r := NewSealRegistry() + if err := r.Register(fakeSeal{id: ""}); err == nil { + t.Error("Register(empty ID) should error") + } +} + +// TestSealRegistry_RegisterReplace covers the replacement path (later +// register wins). +func TestSealRegistry_RegisterReplace(t *testing.T) { + t.Parallel() + r := NewSealRegistry() + _ = r.Register(fakeSeal{id: "id1"}) + _ = r.Register(fakeSeal{id: "id1"}) + if got := r.Get("id1"); got == nil { + t.Error("Get after replace = nil") + } +} + +// TestEncrypt_WrongNonceSize covers the explicit nonce-size check +// branch in Encrypt. +func TestEncrypt_WrongNonceSize(t *testing.T) { + t.Parallel() + s := DefaultSeal() + key, _ := RandomKey(s) + badNonce := make([]byte, s.NonceSize()-1) + _, err := s.Encrypt([]byte("x"), key, badNonce, nil) + if err == nil || !strings.Contains(err.Error(), "nonce size") { + t.Errorf("err = %v, want nonce-size error", err) + } +} + +// TestDecrypt_WrongNonceSize covers the explicit nonce-size check in +// Decrypt. +func TestDecrypt_WrongNonceSize(t *testing.T) { + t.Parallel() + s := DefaultSeal() + key, _ := RandomKey(s) + badNonce := make([]byte, s.NonceSize()-1) + _, err := s.Decrypt([]byte("x"), key, badNonce, nil) + if err == nil || !strings.Contains(err.Error(), "nonce size") { + t.Errorf("err = %v, want nonce-size error", err) + } +} + +// TestEncrypt_WrongKeySize covers the "new aead: bad key" error path. +func TestEncrypt_WrongKeySize(t *testing.T) { + t.Parallel() + s := DefaultSeal() + _, err := s.Encrypt([]byte("x"), []byte("too-short-key"), make([]byte, s.NonceSize()), nil) + if err == nil { + t.Error("Encrypt with bad key should error") + } +} + +// TestDecrypt_WrongKeySize covers the "new aead: bad key" branch in +// Decrypt. +func TestDecrypt_WrongKeySize(t *testing.T) { + t.Parallel() + s := DefaultSeal() + _, err := s.Decrypt([]byte("x"), []byte("too-short-key"), make([]byte, s.NonceSize()), nil) + if err == nil { + t.Error("Decrypt with bad key should error") + } +} + +// TestRandomKey_SizesMatch confirms keys come back at the seal's +// declared size. +func TestRandomKey_SizesMatch(t *testing.T) { + t.Parallel() + s := DefaultSeal() + k1, _ := RandomKey(s) + k2, _ := RandomKey(s) + if len(k1) != s.KeySize() { + t.Errorf("key size %d, want %d", len(k1), s.KeySize()) + } + if bytes.Equal(k1, k2) { + t.Error("two RandomKey calls returned identical keys") + } +} + +// TestRandomNonce_SizesMatch confirms nonces come back at the seal's +// declared size. +func TestRandomNonce_SizesMatch(t *testing.T) { + t.Parallel() + s := DefaultSeal() + n1, _ := RandomNonce(s) + n2, _ := RandomNonce(s) + if len(n1) != s.NonceSize() { + t.Errorf("nonce size %d, want %d", len(n1), s.NonceSize()) + } + if bytes.Equal(n1, n2) { + t.Error("two RandomNonce calls returned identical nonces") + } +} + +// fakeSeal is a stand-in for the seal interface so registry tests can +// exercise Register/Get/IDs without going through chacha20. +type fakeSeal struct{ id string } + +func (f fakeSeal) ID() string { return f.id } +func (f fakeSeal) KeySize() int { return 32 } +func (f fakeSeal) NonceSize() int { return 12 } +func (f fakeSeal) Encrypt(p, k, n, a []byte) ([]byte, error) { return p, nil } +func (f fakeSeal) Decrypt(c, k, n, a []byte) ([]byte, error) { return c, nil } diff --git a/pkg/payment/zz_registry_test.go b/pkg/payment/zz_registry_test.go new file mode 100644 index 0000000..4dcdce9 --- /dev/null +++ b/pkg/payment/zz_registry_test.go @@ -0,0 +1,236 @@ +package payment + +import ( + "context" + "errors" + "testing" +) + +// fakeMethod is a programmable Method for testing the registry. +type fakeMethod struct { + id string + satisfyFn func(Contract) (Receipt, error) + verifyFn func(Contract, Receipt) error +} + +func (m *fakeMethod) ID() string { return m.id } +func (m *fakeMethod) Satisfy(_ context.Context, c Contract) (Receipt, error) { + if m.satisfyFn != nil { + return m.satisfyFn(c) + } + return Receipt{}, ErrCannotSatisfy +} +func (m *fakeMethod) Verify(_ context.Context, c Contract, r Receipt) error { + if m.verifyFn != nil { + return m.verifyFn(c, r) + } + return nil +} + +// fakeEscrow implements Escrow for tests. +type fakeEscrow struct { + id string + holdFn func(Contract, []byte) (EscrowRef, error) + redFn func(EscrowRef, Receipt) ([]byte, error) +} + +func (e *fakeEscrow) ID() string { return e.id } +func (e *fakeEscrow) Hold(_ context.Context, c Contract, k []byte) (EscrowRef, error) { + if e.holdFn != nil { + return e.holdFn(c, k) + } + return EscrowRef{EscrowID: e.id, ContractID: c.ID}, nil +} +func (e *fakeEscrow) Redeem(_ context.Context, ref EscrowRef, r Receipt) ([]byte, error) { + if e.redFn != nil { + return e.redFn(ref, r) + } + return []byte("k"), nil +} + +func TestMethodRegistry_Register_Errors(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + if err := r.Register(nil); err == nil { + t.Error("nil method: expected error") + } + if err := r.Register(&fakeMethod{id: ""}); err == nil { + t.Error("empty ID: expected error") + } +} + +func TestMethodRegistry_HasGetUnregister(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + m := &fakeMethod{id: "m1"} + if err := r.Register(m); err != nil { + t.Fatalf("Register: %v", err) + } + if !r.Has("m1") { + t.Error("Has(m1) = false") + } + if got := r.Get("m1"); got == nil { + t.Error("Get(m1) = nil") + } + if got := r.Get("unknown"); got != nil { + t.Errorf("Get(unknown) = %v, want nil", got) + } + + r.Unregister("m1") + if r.Has("m1") { + t.Error("Has(m1) after Unregister = true") + } + // Idempotent. + r.Unregister("m1") +} + +func TestMethodRegistry_IDs(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + for _, id := range []string{"a", "b", "c"} { + _ = r.Register(&fakeMethod{id: id}) + } + if got := r.IDs(); len(got) != 3 { + t.Errorf("IDs() = %v, want 3 entries", got) + } +} + +func TestMethodRegistry_Satisfy_NoMatch(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + _, err := r.Satisfy(context.Background(), Contract{AcceptedMethods: []string{"none"}}) + if err == nil { + t.Error("expected error when no method matches") + } +} + +func TestMethodRegistry_Satisfy_HappyPath(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + _ = r.Register(&fakeMethod{ + id: "good", + satisfyFn: func(c Contract) (Receipt, error) { + return Receipt{ContractID: c.ID, MethodID: "good"}, nil + }, + }) + got, err := r.Satisfy(context.Background(), Contract{ID: "c1", AcceptedMethods: []string{"good"}}) + if err != nil { + t.Fatalf("Satisfy: %v", err) + } + if got.MethodID != "good" { + t.Errorf("MethodID = %q", got.MethodID) + } +} + +func TestMethodRegistry_Satisfy_LastCannotPropagated(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + _ = r.Register(&fakeMethod{ + id: "cannot", + satisfyFn: func(Contract) (Receipt, error) { return Receipt{}, ErrCannotSatisfy }, + }) + _, err := r.Satisfy(context.Background(), Contract{ID: "c1"}) + if !errors.Is(err, ErrCannotSatisfy) { + t.Errorf("err = %v, want ErrCannotSatisfy", err) + } +} + +func TestMethodRegistry_Satisfy_TransientErrorReturned(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + _ = r.Register(&fakeMethod{ + id: "boom", + satisfyFn: func(Contract) (Receipt, error) { return Receipt{}, errors.New("transient") }, + }) + _, err := r.Satisfy(context.Background(), Contract{ID: "c1"}) + if err == nil { + t.Error("expected transient error to propagate") + } +} + +func TestMethodRegistry_Verify_UnknownMethod(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + err := r.Verify(context.Background(), Contract{}, Receipt{MethodID: "unknown"}) + if !errors.Is(err, ErrUnknownMethod) { + t.Errorf("err = %v, want ErrUnknownMethod", err) + } +} + +func TestMethodRegistry_Verify_DelegatesToMethod(t *testing.T) { + t.Parallel() + r := NewMethodRegistry() + calls := 0 + _ = r.Register(&fakeMethod{ + id: "verify-me", + verifyFn: func(Contract, Receipt) error { calls++; return nil }, + }) + if err := r.Verify(context.Background(), Contract{}, Receipt{MethodID: "verify-me"}); err != nil { + t.Errorf("Verify: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestEscrowRegistry_Register_Errors(t *testing.T) { + t.Parallel() + r := NewEscrowRegistry() + if err := r.Register(nil); err == nil { + t.Error("nil escrow: expected error") + } + if err := r.Register(&fakeEscrow{id: ""}); err == nil { + t.Error("empty ID: expected error") + } +} + +func TestEscrowRegistry_GetUnregisterIDs(t *testing.T) { + t.Parallel() + r := NewEscrowRegistry() + e := &fakeEscrow{id: "e1"} + _ = r.Register(e) + if got := r.Get("e1"); got == nil { + t.Error("Get(e1) = nil") + } + if got := r.Get("unknown"); got != nil { + t.Errorf("Get(unknown) = %v", got) + } + if got := r.IDs(); len(got) != 1 { + t.Errorf("IDs len = %d, want 1", len(got)) + } + r.Unregister("e1") + if got := r.Get("e1"); got != nil { + t.Errorf("Get(e1) after Unregister = %v", got) + } +} + +func TestEscrowRegistry_Redeem_UnknownID(t *testing.T) { + t.Parallel() + r := NewEscrowRegistry() + _, err := r.Redeem(context.Background(), + EscrowRef{EscrowID: "missing"}, Receipt{}) + if err == nil { + t.Error("expected error for unknown escrow ID") + } +} + +func TestEscrowRegistry_PickFor_NoMatch(t *testing.T) { + t.Parallel() + r := NewEscrowRegistry() + _ = r.Register(&fakeEscrow{id: "e1"}) + _, err := r.PickFor(Contract{AcceptedEscrows: []string{"none"}}) + if err == nil { + t.Error("expected error for unconstrained-but-no-match") + } +} + +// TestSealedEnvelope_IDs covers the IDs accessor on Sealed. +func TestSealedEnvelope_IDsList(t *testing.T) { + t.Parallel() + // SealedEnvelope helper IDs returns method+escrow registry IDs. + mr := NewMethodRegistry() + _ = mr.Register(&fakeMethod{id: "m1"}) + if got := mr.IDs(); len(got) != 1 { + t.Errorf("method IDs = %v", got) + } +} diff --git a/plugin/appstore/zz2_supervisor_lifecycle_test.go b/plugin/appstore/zz2_supervisor_lifecycle_test.go new file mode 100644 index 0000000..7b5a1a7 --- /dev/null +++ b/plugin/appstore/zz2_supervisor_lifecycle_test.go @@ -0,0 +1,699 @@ +package appstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// fakeBinaryScript builds a shell-script "binary" under tmpDir that +// exits with the configured code after the requested sleep duration. +// Returns the resolved path AND the sha256 hex digest of the written +// file so the manifest can pin it correctly for verifyBinary. +// +// This is the os/exec mock surface the supervisor lifecycle tests use: +// instead of mocking exec.Command (Go does not allow that), we hand +// the supervisor a real on-disk executable whose behavior we control. +func fakeBinaryScript(t *testing.T, dir, name string, exitCode int, sleep time.Duration) (path, sum string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake-binary scripts use POSIX shell; not portable to Windows") + } + path = filepath.Join(dir, name) + body := fmt.Sprintf("#!/bin/sh\nsleep %.2f\nexit %d\n", sleep.Seconds(), exitCode) + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatalf("write fake binary: %v", err) + } + h := sha256.Sum256([]byte(body)) + return path, hex.EncodeToString(h[:]) +} + +// TestVerifyBinary_OK confirms a matching sha256 passes verifyBinary. +func TestVerifyBinary_OK(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path, sum := fakeBinaryScript(t, dir, "x", 0, 0) + a := &installedApp{ + BinaryPath: path, + Dir: dir, + Manifest: parseDummyManifest(t, "io.verify.ok"), + } + a.Manifest.Binary.SHA256 = sum + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + if err := sup.verifyBinary(a); err != nil { + t.Errorf("verifyBinary: %v", err) + } +} + +// TestVerifyBinary_Mismatch covers the sha256-mismatch return path. +func TestVerifyBinary_Mismatch(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path, _ := fakeBinaryScript(t, dir, "x", 0, 0) + a := &installedApp{ + BinaryPath: path, + Dir: dir, + Manifest: parseDummyManifest(t, "io.verify.mismatch"), + } + // Manifest sha256 is the placeholder all-zeros from parseDummyManifest; + // the actual file hash will differ. + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + err := sup.verifyBinary(a) + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Errorf("err = %v, want sha256 mismatch", err) + } +} + +// TestVerifyBinary_MissingFile covers the open-error path. +func TestVerifyBinary_MissingFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + a := &installedApp{ + BinaryPath: filepath.Join(dir, "does-not-exist"), + Dir: dir, + Manifest: parseDummyManifest(t, "io.verify.missing"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + err := sup.verifyBinary(a) + if err == nil || !strings.Contains(err.Error(), "open binary") { + t.Errorf("err = %v, want open binary error", err) + } +} + +// TestSpawn_FastExitTriggersExitCode runs a real spawn against a fake +// binary that exits cleanly with code 0. Drives the cmd.Start → +// applyChildResourceLimits → cmd.Wait happy path. +func TestSpawn_FastExitTriggersExitCode(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.spawn.fast") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + path, _ := fakeBinaryScript(t, appDir, "bin", 0, 0) + + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.spawn.fast"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + code := sup.spawn(ctx, a) + if code != 0 { + t.Errorf("spawn exit code = %d, want 0", code) + } +} + +// TestSpawn_NonZeroExitPropagates runs against a fake binary that +// exits with code 42 and confirms spawn surfaces that as the return +// value. Drives the *exec.ExitError branch of cmd.Wait. +func TestSpawn_NonZeroExitPropagates(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.spawn.exit42") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + path, _ := fakeBinaryScript(t, appDir, "bin", 42, 0) + + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.spawn.exit42"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + code := sup.spawn(ctx, a) + if code != 42 { + t.Errorf("spawn exit code = %d, want 42", code) + } +} + +// TestSpawn_StartFailure exercises the "missing binary" branch where +// exec.Start fails outright. Should return -1 and emit a spawn-fail +// audit line. +func TestSpawn_StartFailure(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.spawn.nostart") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + a := &installedApp{ + Dir: appDir, + BinaryPath: filepath.Join(appDir, "does-not-exist"), + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.spawn.nostart"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + code := sup.spawn(ctx, a) + if code != -1 { + t.Errorf("spawn exit code = %d, want -1 on Start failure", code) + } + // spawn-fail audit line should have landed. + logBody, err := os.ReadFile(filepath.Join(appDir, supervisorLogName)) + if err != nil { + t.Fatalf("read audit log: %v", err) + } + if !strings.Contains(string(logBody), `"spawn-fail"`) { + t.Errorf("expected spawn-fail event in audit log, got: %s", logBody) + } +} + +// TestSpawn_StaleSocketIsCleaned drives the "drop stale socket" branch +// at the top of spawn. +func TestSpawn_StaleSocketIsCleaned(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.spawn.stale") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + socketPath := filepath.Join(appDir, "app.sock") + if err := os.WriteFile(socketPath, []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + path, _ := fakeBinaryScript(t, appDir, "bin", 0, 0) + + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: socketPath, + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.spawn.stale"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = sup.spawn(ctx, a) + // After spawn returns the fake binary has exited; the supervisor + // removed the stale socket at the start. waitReady might or might + // not have observed it before the binary exited — the assertion is + // that the supervisor's pre-spawn rm step ran (i.e. the file no + // longer matches its original "stale" contents). + if body, err := os.ReadFile(socketPath); err == nil && string(body) == "stale" { + t.Errorf("stale socket was not cleaned before spawn") + } +} + +// TestWaitReady_SocketAppears drives waitReady with a real socket file +// that's created mid-wait; confirms ready flips to true. +func TestWaitReady_SocketAppears(t *testing.T) { + t.Parallel() + dir := t.TempDir() + socketPath := filepath.Join(dir, "app.sock") + a := &installedApp{ + SocketPath: socketPath, + Dir: dir, + Manifest: parseDummyManifest(t, "io.waitready.ok"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + sup.waitReady(context.Background(), a, 2*time.Second) + }() + + // Create the socket after a short delay. + time.Sleep(75 * time.Millisecond) + if err := os.WriteFile(socketPath, nil, 0o600); err != nil { + t.Fatal(err) + } + wg.Wait() + + sup.mu.RLock() + defer sup.mu.RUnlock() + if !sup.ready["io.waitready.ok"] { + t.Errorf("waitReady did not flip ready after socket creation") + } +} + +// TestWaitReady_Timeout covers the "socket never appears" branch. +func TestWaitReady_Timeout(t *testing.T) { + t.Parallel() + dir := t.TempDir() + a := &installedApp{ + SocketPath: filepath.Join(dir, "never.sock"), + Dir: dir, + Manifest: parseDummyManifest(t, "io.waitready.timeout"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.waitReady(context.Background(), a, 50*time.Millisecond) + sup.mu.RLock() + defer sup.mu.RUnlock() + if sup.ready["io.waitready.timeout"] { + t.Errorf("ready should be false on timeout") + } +} + +// TestWaitReady_CtxCanceled covers the ctx.Err() branch of waitReady. +func TestWaitReady_CtxCanceled(t *testing.T) { + t.Parallel() + dir := t.TempDir() + a := &installedApp{ + SocketPath: filepath.Join(dir, "never.sock"), + Dir: dir, + Manifest: parseDummyManifest(t, "io.waitready.cancel"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sup.waitReady(ctx, a, time.Second) + sup.mu.RLock() + defer sup.mu.RUnlock() + if sup.ready["io.waitready.cancel"] { + t.Errorf("ready should not be set when ctx is canceled") + } +} + +// TestAwaitReady_Timeout covers the polling-timeout branch. +func TestAwaitReady_Timeout(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + if got := sup.awaitReady(context.Background(), "io.never", 50*time.Millisecond); got { + t.Errorf("awaitReady = true, want false on timeout") + } +} + +// TestAwaitReady_FlipsToReadyMidPoll exercises the success branch +// where ready flips true during the poll. +func TestAwaitReady_FlipsToReadyMidPoll(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + go func() { + time.Sleep(50 * time.Millisecond) + sup.markReady("io.delayed") + }() + if !sup.awaitReady(context.Background(), "io.delayed", 2*time.Second) { + t.Errorf("awaitReady should return true after flip") + } +} + +// TestSuperviseOne_VerifyFailLoops drives superviseOne against an app +// whose binary doesn't match its pinned sha256, then cancels ctx +// shortly after to confirm the verify-fail backoff path returns. +func TestSuperviseOne_VerifyFailLoops(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.supervise.verifyfail") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + // Real file but wrong sha256 in the dummy manifest. + path := filepath.Join(appDir, "bin") + if err := os.WriteFile(path, []byte("real but wrong-hash"), 0o755); err != nil { + t.Fatal(err) + } + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.supervise.verifyfail"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + sup.superviseOne(ctx, a) + close(done) + }() + // Wait long enough for at least one verify-fail audit line to land. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if body, err := os.ReadFile(filepath.Join(appDir, supervisorLogName)); err == nil && + strings.Contains(string(body), `"verify-fail"`) { + break + } + time.Sleep(20 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(35 * time.Second): + t.Fatal("superviseOne did not return after ctx cancel (verify-fail backoff stuck)") + } + body, _ := os.ReadFile(filepath.Join(appDir, supervisorLogName)) + if !strings.Contains(string(body), `"verify-fail"`) { + t.Errorf("expected verify-fail line in audit log, got: %s", body) + } +} + +// TestSuperviseOne_SpawnSuspendThenExits drives the crash-loop path: +// run with a binary that exits immediately, force the suspend branch +// to fire by lowering the cap via injected crash records, then confirm +// the suspended marker lands. +func TestSuperviseOne_CrashLoopSuspends(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.supervise.crashloop") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + path, sum := fakeBinaryScript(t, appDir, "bin", 1, 0) + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.supervise.crashloop"), + } + a.Manifest.Binary.SHA256 = sum + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + // Pre-fill crash record so the very first exit trips the cap. + now := time.Now() + sup.crashes["io.supervise.crashloop"] = &crashRecord{ + times: []time.Time{now, now, now, now, now, now}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + done := make(chan struct{}) + go func() { + sup.superviseOne(ctx, a) + close(done) + }() + select { + case <-done: + case <-time.After(8 * time.Second): + t.Fatal("superviseOne did not return after crash-loop cap") + } + // Suspended marker should now exist. + if _, err := os.Stat(filepath.Join(appDir, suspendedMarkerName)); err != nil { + t.Errorf(".suspended marker missing after crash-loop cap: %v", err) + } + body, _ := os.ReadFile(filepath.Join(appDir, supervisorLogName)) + if !strings.Contains(string(body), `"suspend"`) { + t.Errorf("expected suspend event in audit log, got: %s", body) + } +} + +// TestResolveUnder_EmptyPath covers the empty-path error branch. +func TestResolveUnder_EmptyPath(t *testing.T) { + t.Parallel() + if _, err := resolveUnder("/tmp", ""); err == nil { + t.Error("expected error for empty rel path") + } +} + +// TestResolveUnder_AbsolutePath covers the absolute-path error branch. +func TestResolveUnder_AbsolutePath(t *testing.T) { + t.Parallel() + if _, err := resolveUnder("/tmp", "/etc/passwd"); err == nil { + t.Error("expected error for absolute rel path") + } +} + +// TestResolveUnder_Escape covers the path-escapes-base branch. +func TestResolveUnder_Escape(t *testing.T) { + t.Parallel() + if _, err := resolveUnder("/tmp/app", "../../etc/passwd"); err == nil { + t.Error("expected error for path escaping base") + } +} + +// TestResolveUnder_OK confirms the happy path returns a path under base. +func TestResolveUnder_OK(t *testing.T) { + t.Parallel() + got, err := resolveUnder("/tmp/app", "bin/wallet") + if err != nil { + t.Fatalf("resolveUnder: %v", err) + } + if !strings.HasSuffix(got, "/bin/wallet") { + t.Errorf("got %q, want path ending in /bin/wallet", got) + } +} + +// TestScanInstalled_MissingRoot covers the os.IsNotExist branch. +func TestScanInstalled_MissingRoot(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "does-not-exist") + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Errorf("err = %v, want nil for missing root", err) + } + if len(apps) != 0 { + t.Errorf("apps = %v, want empty", apps) + } +} + +// TestScanInstalled_SkipsNonDirs confirms file entries in the root +// don't crash the scan. +func TestScanInstalled_SkipsNonDirs(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "stray-file"), nil, 0o644); err != nil { + t.Fatal(err) + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 0 { + t.Errorf("apps = %v, want empty (stray file should be ignored)", apps) + } +} + +// TestScanInstalled_RejectsTraversalPath drops a manifest whose +// binary.path tries to escape via "..", confirms scanInstalled drops it. +func TestScanInstalled_RejectsTraversalPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.evil.app") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + body := `{ + "id": "io.evil.app", + "manifest_version": 1, + "app_version": "0.0.0", + "protection": "shareable", + "binary": {"runtime": "go", "path": "../../../bin/sh", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + "exposes": ["io.evil.app.method"], + "grants": [{"cap": "fs.read", "target": "$APP/data.db"}], + "store": {"publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "signature": "sig:p"} + }` + if err := os.WriteFile(filepath.Join(appDir, "manifest.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 0 { + t.Errorf("apps = %v, want empty (traversal must be rejected)", apps) + } +} + +// TestScanInstalled_RejectsSymlinkBinary drops a manifest whose binary +// path resolves to a symlink, confirms scanInstalled rejects it. +func TestScanInstalled_RejectsSymlinkBinary(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlinks unreliable on Windows test runners") + } + dir := t.TempDir() + appDir := filepath.Join(dir, "io.symlink.app") + if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o700); err != nil { + t.Fatal(err) + } + // Drop a symlink at appDir/bin/x → /bin/sh + link := filepath.Join(appDir, "bin", "x") + if err := os.Symlink("/bin/sh", link); err != nil { + t.Fatalf("symlink: %v", err) + } + body := `{ + "id": "io.symlink.app", + "manifest_version": 1, + "app_version": "0.0.0", + "protection": "shareable", + "binary": {"runtime": "go", "path": "bin/x", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}, + "exposes": ["io.symlink.app.method"], + "grants": [{"cap": "fs.read", "target": "$APP/data.db"}], + "store": {"publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "signature": "sig:p"} + }` + if err := os.WriteFile(filepath.Join(appDir, "manifest.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + apps, err := sup.scanInstalled() + if err != nil { + t.Fatal(err) + } + if len(apps) != 0 { + t.Errorf("apps = %v, want empty (symlink binary must be rejected)", apps) + } +} + +// TestApplyChildResourceLimits_NonLinuxOrInvalidPID exercises the +// stub on non-linux platforms; on linux, calling against an invalid +// PID logs but does not panic. +func TestApplyChildResourceLimits_Smoke(t *testing.T) { + t.Parallel() + // Pass an invalid PID; the function is best-effort and must not panic. + logger := newQuietLogger(t) + applyChildResourceLimits(0, logger) +} + +// TestService_AppsBeforeStart returns nil per the docstring. +func TestService_AppsBeforeStart(t *testing.T) { + t.Parallel() + s := &Service{} + if got := s.Apps(); got != nil { + t.Errorf("Apps before Start = %v, want nil", got) + } +} + +// TestService_DoubleStartReturnsError covers the "already started" +// guard in Service.Start. +func TestService_DoubleStartReturnsError(t *testing.T) { + t.Parallel() + s := NewService(Config{InstallRoot: t.TempDir()}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.Start(ctx, Deps{}); err != nil { + t.Fatalf("first Start: %v", err) + } + defer s.Stop(ctx) + if err := s.Start(ctx, Deps{}); err == nil { + t.Error("double Start: expected 'already started' error") + } +} + +// TestService_StopIdempotent covers the early-return branch when sup +// is nil. +func TestService_StopIdempotent(t *testing.T) { + t.Parallel() + s := NewService(Config{InstallRoot: t.TempDir()}) + if err := s.Stop(context.Background()); err != nil { + t.Errorf("Stop on never-started service: %v", err) + } +} + +// TestService_StartFailsWhenInstallRootIsAFile drives the MkdirAll +// failure branch (passing a path that's an existing regular file). +func TestService_StartFailsWhenInstallRootIsAFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "as-file") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s := NewService(Config{InstallRoot: path}) + err := s.Start(context.Background(), Deps{}) + if err == nil { + t.Error("expected Start to fail when InstallRoot is a file") + } +} + +// TestCatalogPubkeyIsPlaceholder_NonZero confirms a key with any +// non-zero byte is not flagged as the placeholder. +func TestCatalogPubkeyIsPlaceholder_NonZero(t *testing.T) { + t.Parallel() + pk := make([]byte, 32) + pk[5] = 0xAA + if catalogPubkeyIsPlaceholder(pk) { + t.Error("non-zero key flagged as placeholder") + } +} + +// TestCatalogPubkeyIsPlaceholder_EmptyAndNil exercises the empty / nil +// branch. +func TestCatalogPubkeyIsPlaceholder_EmptyAndNil(t *testing.T) { + t.Parallel() + if !catalogPubkeyIsPlaceholder(nil) { + t.Error("nil should be placeholder") + } + if !catalogPubkeyIsPlaceholder([]byte{}) { + t.Error("empty should be placeholder") + } +} + +// TestService_Call_UnknownAppReturnsErrAppNotInstalled drives the full +// Service.Call → sup.Call path for the unknown-app branch. +func TestService_Call_UnknownAppReturnsErrAppNotInstalled(t *testing.T) { + t.Parallel() + s := NewService(Config{InstallRoot: t.TempDir()}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.Start(ctx, Deps{}); err != nil { + t.Fatalf("Start: %v", err) + } + defer s.Stop(ctx) + err := s.Call(ctx, "io.unknown", "method", nil, nil) + if !errors.Is(err, ErrAppNotInstalled) { + t.Errorf("err = %v, want ErrAppNotInstalled", err) + } +} + +// TestSupervisor_Call_DialFailsWhenNoServer wires up an installed app +// with a ready bit set but no socket → dial fails fast. +func TestSupervisor_Call_DialFailsWhenNoServer(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.dial.fail") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["io.dial.fail"] = &installedApp{ + Dir: appDir, + SocketPath: filepath.Join(appDir, "missing.sock"), + Manifest: parseDummyManifest(t, "io.dial.fail"), + } + sup.ready["io.dial.fail"] = true + sup.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := sup.Call(ctx, "io.dial.fail", "any", nil, nil) + if err == nil || !strings.Contains(err.Error(), "dial") { + t.Errorf("err = %v, want dial error", err) + } +} + +// TestSupervisor_Get_NilSafe covers nil-receiver smoke on Get. +func TestSupervisor_Get_OK(t *testing.T) { + t.Parallel() + sup := newSupervisor(Config{InstallRoot: t.TempDir()}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["x"] = &installedApp{Manifest: parseDummyManifest(t, "x")} + sup.mu.Unlock() + if got := sup.Get("x"); got == nil { + t.Error("Get returned nil for installed app") + } +} diff --git a/plugin/appstore/zz3_supervisor_call_test.go b/plugin/appstore/zz3_supervisor_call_test.go new file mode 100644 index 0000000..37a0ad4 --- /dev/null +++ b/plugin/appstore/zz3_supervisor_call_test.go @@ -0,0 +1,254 @@ +package appstore + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +// startAppSocket runs an ipc.Serve loop on a unix socket at the supplied +// path, dispatching the named method to handler. Returns a cleanup +// func that closes the listener. +func startAppSocket(t *testing.T, socketPath, method string, handler ipc.Handler) func() { + t.Helper() + l, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen unix %s: %v", socketPath, err) + } + d := ipc.NewDispatcher() + d.Register(method, handler) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, err := l.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + _ = ipc.Serve(context.Background(), c, d) + }(conn) + } + }() + return func() { + _ = l.Close() + wg.Wait() + } +} + +// shortSocketPath returns a unix-socket path short enough to fit in +// sockaddr_un (macOS: ~104 bytes). t.TempDir paths on macOS often +// exceed that, so we create the socket directly under /tmp. +func shortSocketPath(t *testing.T, name string) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "as-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, name) +} + +// TestSupervisor_Call_HappyPath wires a real listening app.sock and +// drives the full Service.Call → supervisor.Call → ipc.Call path, +// hitting the dialer-deadline + successful IPC branches. +func TestSupervisor_Call_HappyPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.call.happy") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + socketPath := shortSocketPath(t, "app.sock") + cleanup := startAppSocket(t, socketPath, "echo", + func(_ context.Context, req *ipc.Envelope) (json.RawMessage, error) { + return req.Payload, nil + }) + defer cleanup() + + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["io.call.happy"] = &installedApp{ + Dir: appDir, + SocketPath: socketPath, + Manifest: parseDummyManifest(t, "io.call.happy"), + } + sup.ready["io.call.happy"] = true + sup.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var out string + if err := sup.Call(ctx, "io.call.happy", "echo", "ping", &out); err != nil { + t.Fatalf("Call: %v", err) + } + if out != "ping" { + t.Errorf("out = %q, want %q", out, "ping") + } +} + +// TestSupervisor_Call_PropagatesServerError exercises the EnvErr path +// — the app returns an error and Call surfaces it. +func TestSupervisor_Call_PropagatesServerError(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.call.err") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + socketPath := shortSocketPath(t, "app.sock") + cleanup := startAppSocket(t, socketPath, "boom", + func(_ context.Context, _ *ipc.Envelope) (json.RawMessage, error) { + return nil, errors.New("app rejected") + }) + defer cleanup() + + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["io.call.err"] = &installedApp{ + Dir: appDir, + SocketPath: socketPath, + Manifest: parseDummyManifest(t, "io.call.err"), + } + sup.ready["io.call.err"] = true + sup.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := sup.Call(ctx, "io.call.err", "boom", nil, nil) + if err == nil || !strings.Contains(err.Error(), "app rejected") { + t.Errorf("err = %v, want 'app rejected'", err) + } +} + +// TestRescanForResume_IntegrationViaRunLoop drops a .resume marker into +// an installed app's dir, lets the supervisor's run loop pick it up, +// and confirms rescanForResume's return value lands a fresh supervise +// goroutine (audit log shows supervise-start after the marker drop). +func TestRescanForResume_IntegrationViaRunLoop(t *testing.T) { + t.Parallel() + root := t.TempDir() + appDir := writeValidAppDir(t, root, "io.resume.run") + + svc := NewService(Config{ + InstallRoot: root, + RescanInterval: 30 * time.Millisecond, + }) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := svc.Start(ctx, Deps{}); err != nil { + t.Fatalf("Start: %v", err) + } + defer svc.Stop(ctx) + + // Wait until the initial supervise-start landed. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if body, _ := os.ReadFile(filepath.Join(appDir, supervisorLogName)); strings.Contains(string(body), "supervise-start") { + break + } + time.Sleep(20 * time.Millisecond) + } + + // Drop the resume marker and let the rescan tick re-invoke + // rescanForResume; the integration completes when a "resume" audit + // line appears in the log. + if err := os.WriteFile(filepath.Join(appDir, ".resume"), nil, 0o644); err != nil { + t.Fatal(err) + } + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + body, _ := os.ReadFile(filepath.Join(appDir, supervisorLogName)) + if strings.Contains(string(body), `"resume"`) { + return // success + } + time.Sleep(20 * time.Millisecond) + } + body, _ := os.ReadFile(filepath.Join(appDir, supervisorLogName)) + t.Errorf("rescanForResume did not emit a resume audit line within 2s; log=%s", body) +} + +// TestRotateAuditIfLarge_NoActiveLogIsNoOp covers the "first write or +// perm issue" branch where os.Stat returns an error. +func TestRotateAuditIfLarge_NoActiveLogIsNoOp(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir, AuditLogMaxBytes: 1}, Deps{}, newQuietLogger(t)) + // Should not panic or create anything. + sup.rotateAuditIfLarge(dir) + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("rotateAuditIfLarge with no active log created files: %v", entries) + } +} + +// TestWriteAuditLine_OpenFailure simulates an unwritable app dir so +// the os.OpenFile inside writeAuditLine fails — covers the audit-open +// error branch. Audit is best-effort: this must NOT panic and must not +// corrupt the supervisor. +func TestWriteAuditLine_OpenFailure(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create the app dir read-only so we can't open supervisor.log for + // writing. (root will bypass the perm; skip if running as root.) + if os.Geteuid() == 0 { + t.Skip("running as root; file mode perms ignored") + } + appDir := filepath.Join(dir, "io.audit.locked") + if err := os.Mkdir(appDir, 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(appDir, 0o700) //nolint:errcheck // best-effort teardown + app := &installedApp{Dir: appDir, Manifest: parseDummyManifest(t, "io.audit.locked")} + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + // Must not panic — the supervisor logs the open error and returns. + sup.writeAuditLine(app, auditEvent{Event: "spawn", PID: 1}) +} + +// TestSuperviseOne_VerifyFailRetriesPastBackoff drives a verify-fail +// loop and cancels after the second iteration, hitting the "select +// case <-time.After(maxBackoff)" branch. +func TestSuperviseOne_VerifyFailRetriesPastBackoff(t *testing.T) { + t.Parallel() + dir := t.TempDir() + appDir := filepath.Join(dir, "io.supervise.retry") + if err := os.MkdirAll(appDir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(appDir, "bin") + if err := os.WriteFile(path, []byte("wrong-hash"), 0o755); err != nil { + t.Fatal(err) + } + a := &installedApp{ + Dir: appDir, + BinaryPath: path, + SocketPath: filepath.Join(appDir, "app.sock"), + DBPath: filepath.Join(appDir, "data.db"), + IDPath: filepath.Join(appDir, "identity.json"), + Manifest: parseDummyManifest(t, "io.supervise.retry"), + } + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + sup.superviseOne(ctx, a) + close(done) + }() + select { + case <-done: + case <-time.After(40 * time.Second): + t.Fatal("superviseOne stuck in verify-fail loop") + } +} diff --git a/plugin/appstore/zz_service_call_test.go b/plugin/appstore/zz_service_call_test.go new file mode 100644 index 0000000..91355e7 --- /dev/null +++ b/plugin/appstore/zz_service_call_test.go @@ -0,0 +1,63 @@ +package appstore + +import ( + "context" + "errors" + "testing" +) + +func TestService_Call_NotStarted(t *testing.T) { + t.Parallel() + s := &Service{} + err := s.Call(context.Background(), "io.test", "method", nil, nil) + if err == nil { + t.Error("expected 'service not started' error") + } +} + +// fakeIdentityAddr satisfies the identityAddresser interface in supervisor.go. +type fakeIdentityAddr struct{ addr string } + +func (f *fakeIdentityAddr) Address() string { return f.addr } +func (f *fakeIdentityAddr) NodeID() uint32 { return 7 } + +func TestDaemonAddrFromDeps_NonEmptyAddressReturned(t *testing.T) { + t.Parallel() + got := daemonAddrFromDeps(Deps{Identity: &fakeIdentityAddr{addr: "1:0001.0002.0003"}}) + if got != "1:0001.0002.0003" { + t.Errorf("got %q", got) + } +} + +func TestDaemonAddrFromDeps_EmptyAddressFallsBackToSentinel(t *testing.T) { + t.Parallel() + got := daemonAddrFromDeps(Deps{Identity: &fakeIdentityAddr{addr: ""}}) + if got != "0:0001.0000.0000" { + t.Errorf("got %q, want sentinel", got) + } +} + +// fakeIdentityNoAddr satisfies coreapi.Identity but NOT the identityAddresser +// interface (no Address method) — exercises the type-assertion miss branch. +type fakeIdentityNoAddr struct{} + +func (fakeIdentityNoAddr) NodeID() uint32 { return 1 } + +func TestDaemonAddrFromDeps_IdentityWithoutAddressFallsBack(t *testing.T) { + t.Parallel() + got := daemonAddrFromDeps(Deps{Identity: fakeIdentityNoAddr{}}) + if got != "0:0001.0000.0000" { + t.Errorf("got %q, want sentinel", got) + } +} + +// TestSupervisor_Call_NilArgsAndOut covers the args/out nil-passthrough path. +func TestSupervisor_Call_NilArgsAndOut(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + err := sup.Call(context.Background(), "io.not.installed", "method", nil, nil) + if !errors.Is(err, ErrAppNotInstalled) { + t.Errorf("want ErrAppNotInstalled, got %v", err) + } +} diff --git a/plugin/appstore/zz_supervisor_simple_test.go b/plugin/appstore/zz_supervisor_simple_test.go new file mode 100644 index 0000000..4a93c2f --- /dev/null +++ b/plugin/appstore/zz_supervisor_simple_test.go @@ -0,0 +1,136 @@ +package appstore + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSupervisor_Apps_EmptyOnFresh(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + if got := sup.Apps(); len(got) != 0 { + t.Errorf("Apps on fresh sup = %v", got) + } +} + +func TestSupervisor_Get_UnknownReturnsNil(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + if got := sup.Get("not-installed"); got != nil { + t.Errorf("Get(unknown) = %v, want nil", got) + } +} + +func TestSupervisor_Get_AfterRegister(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + app := &installedApp{ + Dir: dir, + Manifest: parseDummyManifest(t, "io.test.app"), + } + sup.mu.Lock() + sup.installed["io.test.app"] = app + sup.mu.Unlock() + + if got := sup.Get("io.test.app"); got == nil { + t.Error("Get after register = nil") + } + if got := sup.Apps(); len(got) != 1 { + t.Errorf("Apps len = %d, want 1", len(got)) + } +} + +func TestSupervisor_Call_NotInstalled(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + err := sup.Call(context.Background(), "io.no.such", "method", nil, nil) + if !errors.Is(err, ErrAppNotInstalled) { + t.Errorf("err = %v, want ErrAppNotInstalled", err) + } +} + +func TestSupervisor_Call_NotReady(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["io.test.app"] = &installedApp{ + Dir: dir, + Manifest: parseDummyManifest(t, "io.test.app"), + } + sup.mu.Unlock() + + // Use a context that's about to cancel so awaitReady's poll returns false fast. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := sup.Call(ctx, "io.test.app", "method", nil, nil) + if !errors.Is(err, ErrAppNotReady) { + t.Errorf("err = %v, want ErrAppNotReady", err) + } +} + +func TestSupervisor_MarkReadyAndNotReady(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.markReady("io.x") + sup.mu.RLock() + if !sup.ready["io.x"] { + t.Error("after markReady: ready[io.x] = false") + } + sup.mu.RUnlock() + + sup.markNotReady("io.x") + sup.mu.RLock() + defer sup.mu.RUnlock() + if _, ok := sup.ready["io.x"]; ok { + t.Error("after markNotReady: still in ready map") + } +} + +func TestSupervisor_AwaitReady_AlreadyReady(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.markReady("io.fast") + if !sup.awaitReady(context.Background(), "io.fast", time.Second) { + t.Error("awaitReady on ready app: want true") + } +} + +func TestApps_SuspendedFlagFromCrashRecord(t *testing.T) { + t.Parallel() + dir := t.TempDir() + sup := newSupervisor(Config{InstallRoot: dir}, Deps{}, newQuietLogger(t)) + sup.mu.Lock() + sup.installed["io.crash"] = &installedApp{ + Dir: dir, + Manifest: parseDummyManifest(t, "io.crash"), + } + sup.crashes["io.crash"] = &crashRecord{suspended: true} + sup.mu.Unlock() + + got := sup.Apps() + if len(got) != 1 { + t.Fatalf("Apps len = %d", len(got)) + } + if !got[0].Suspended { + t.Error("Suspended flag should be true") + } +} + +func TestDaemonAddrFromDeps_NilDepsReturnsSentinel(t *testing.T) { + t.Parallel() + // With no Identity wired the function falls back to a non-routable + // sentinel — just verify it returns SOMETHING (covers the fallback). + got := daemonAddrFromDeps(Deps{}) + if got == "" { + t.Error("expected non-empty sentinel fallback") + } +}