|
| 1 | +package publish |
| 2 | + |
| 3 | +import ( |
| 4 | + "archive/tar" |
| 5 | + "bytes" |
| 6 | + "compress/gzip" |
| 7 | + "encoding/json" |
| 8 | + "io" |
| 9 | + "strings" |
| 10 | + "testing" |
| 11 | +) |
| 12 | + |
| 13 | +// sampleCLISubmission is a CLI-backed submission with both method shapes: |
| 14 | +// an enumerated subcommand (status) and a passthrough that fronts the whole |
| 15 | +// tool (exec) — the "translate all CLI commands" surface. |
| 16 | +func sampleCLISubmission() Submission { |
| 17 | + return Submission{ |
| 18 | + ID: "io.pilot.gh", Version: "0.1.0", Description: "Fronts the gh CLI over the app store.", Email: "dev@acme.example", |
| 19 | + Backend: SubBackend{Type: "cli", Command: []string{"gh"}, EnvPassthrough: []string{"GH_TOKEN"}}, |
| 20 | + Methods: []SubMethod{ |
| 21 | + {Name: "gh.status", Description: "Show gh auth status.", Latency: "fast", |
| 22 | + CLI: SubCLIRoute{Args: []string{"auth", "status"}}}, |
| 23 | + {Name: "gh.exec", Description: "Run any gh subcommand.", Latency: "med", |
| 24 | + Params: []SubParam{{Name: "args", Type: "array", Description: "verbatim argv forwarded to gh"}}, |
| 25 | + CLI: SubCLIRoute{Passthrough: true}}, |
| 26 | + }, |
| 27 | + Listing: SubListing{DisplayName: "GitHub CLI", License: "MIT", Categories: []string{"dev"}}, |
| 28 | + Vendor: SubVendor{Name: "Acme", AgentUsage: "agents drive gh", Capabilities: "github"}, |
| 29 | + } |
| 30 | +} |
| 31 | + |
| 32 | +// fileFromTarball pulls one path out of a gzipped bundle tarball. |
| 33 | +func fileFromTarball(t *testing.T, tarball []byte, name string) []byte { |
| 34 | + t.Helper() |
| 35 | + gz, err := gzip.NewReader(bytes.NewReader(tarball)) |
| 36 | + if err != nil { |
| 37 | + t.Fatal(err) |
| 38 | + } |
| 39 | + tr := tar.NewReader(gz) |
| 40 | + for { |
| 41 | + h, err := tr.Next() |
| 42 | + if err == io.EOF { |
| 43 | + break |
| 44 | + } |
| 45 | + if err != nil { |
| 46 | + t.Fatal(err) |
| 47 | + } |
| 48 | + if h.Name == name { |
| 49 | + b, _ := io.ReadAll(tr) |
| 50 | + return b |
| 51 | + } |
| 52 | + } |
| 53 | + t.Fatalf("%s not found in tarball", name) |
| 54 | + return nil |
| 55 | +} |
| 56 | + |
| 57 | +// TestCLISubmissionValidates pins the submission-level CLI rules: a well-formed |
| 58 | +// cli submission passes, and the common misconfigurations are rejected with a |
| 59 | +// friendly message (fast, no build). |
| 60 | +func TestCLISubmissionValidates(t *testing.T) { |
| 61 | + t.Parallel() |
| 62 | + if errs := sampleCLISubmission().Validate(); len(errs) != 0 { |
| 63 | + t.Fatalf("a well-formed cli submission must validate, got: %v", errs) |
| 64 | + } |
| 65 | + |
| 66 | + noCmd := sampleCLISubmission() |
| 67 | + noCmd.Backend.Command = nil |
| 68 | + if !hasSubErr(noCmd.Validate(), "CLI backend requires a command") { |
| 69 | + t.Errorf("missing command should be rejected, got: %v", noCmd.Validate()) |
| 70 | + } |
| 71 | + |
| 72 | + badPass := sampleCLISubmission() |
| 73 | + badPass.Methods[1].CLI.Args = []string{"oops"} // passthrough + args is contradictory |
| 74 | + if !hasSubErr(badPass.Validate(), "passthrough takes argv") { |
| 75 | + t.Errorf("passthrough+args should be rejected, got: %v", badPass.Validate()) |
| 76 | + } |
| 77 | + |
| 78 | + emptyRoute := sampleCLISubmission() |
| 79 | + emptyRoute.Methods[0].CLI = SubCLIRoute{} // no args, no flags, no passthrough |
| 80 | + if !hasSubErr(emptyRoute.Validate(), "needs args, params_as_flags, or passthrough") { |
| 81 | + t.Errorf("empty cli route should be rejected, got: %v", emptyRoute.Validate()) |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +// TestCLIHelpPreviewShowsPassthroughArgs verifies the live preview renders the |
| 86 | +// correct pilotctl line for a passthrough method: an {"args":[...]} payload, not |
| 87 | +// a params skeleton. |
| 88 | +func TestCLIHelpPreviewShowsPassthroughArgs(t *testing.T) { |
| 89 | + t.Parallel() |
| 90 | + _, cmds := sampleCLISubmission().HelpPreview() |
| 91 | + joined := strings.Join(cmds, "\n") |
| 92 | + if !strings.Contains(joined, `call io.pilot.gh gh.exec '{"args":[`) { |
| 93 | + t.Errorf("passthrough method should preview an args[] payload; got:\n%s", joined) |
| 94 | + } |
| 95 | + if !strings.Contains(joined, "call io.pilot.gh gh.status") { |
| 96 | + t.Errorf("enumerated method missing from preview:\n%s", joined) |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +// TestCLISubmissionBuildsAndVerifies is the publish-path e2e: a CLI submission |
| 101 | +// builds through the real pipeline (scaffold → cross-compile → sign → catalogue |
| 102 | +// self-verify) for every platform. Because BuildBundle self-verifies through the |
| 103 | +// exact catalogue gate, a successful build PROVES the proc.exec manifest passes |
| 104 | +// validation — the gate that rejected cli apps before this capability landed. |
| 105 | +// It then asserts the shipped manifest declares proc.exec (scoped to the command) |
| 106 | +// and is guarded. |
| 107 | +func TestCLISubmissionBuildsAndVerifies(t *testing.T) { |
| 108 | + if testing.Short() { |
| 109 | + t.Skip("cross-compiles the cli adapter for all platforms; skipped under -short") |
| 110 | + } |
| 111 | + priv, err := LoadOrCreateKey(t.TempDir() + "/k.key") |
| 112 | + if err != nil { |
| 113 | + t.Fatal(err) |
| 114 | + } |
| 115 | + sub := sampleCLISubmission() |
| 116 | + if errs := sub.Validate(); len(errs) != 0 { |
| 117 | + t.Fatalf("submission invalid: %v", errs) |
| 118 | + } |
| 119 | + |
| 120 | + b, err := BuildBundle(sub.ToConfig(), priv) |
| 121 | + if err != nil { |
| 122 | + t.Fatalf("BuildBundle (implies catalogue self-verify) failed for a proc.exec app: %v", err) |
| 123 | + } |
| 124 | + if len(b.Platforms) != len(DefaultPlatforms) { |
| 125 | + t.Fatalf("want %d platforms, got %d", len(DefaultPlatforms), len(b.Platforms)) |
| 126 | + } |
| 127 | + |
| 128 | + // The shipped manifest must carry the hardened proc.exec grant + guarded. |
| 129 | + mfRaw := fileFromTarball(t, b.Primary().Tarball, "./manifest.json") |
| 130 | + var mf struct { |
| 131 | + Protection string `json:"protection"` |
| 132 | + Grants []struct { |
| 133 | + Cap, Target string |
| 134 | + } `json:"grants"` |
| 135 | + } |
| 136 | + if err := json.Unmarshal(mfRaw, &mf); err != nil { |
| 137 | + t.Fatalf("parse shipped manifest: %v", err) |
| 138 | + } |
| 139 | + if mf.Protection != "guarded" { |
| 140 | + t.Errorf("cli app must ship protection=guarded, got %q", mf.Protection) |
| 141 | + } |
| 142 | + var procExec string |
| 143 | + for _, g := range mf.Grants { |
| 144 | + if g.Cap == "proc.exec" { |
| 145 | + procExec = g.Target |
| 146 | + } |
| 147 | + } |
| 148 | + if procExec != "gh" { |
| 149 | + t.Errorf("manifest must declare proc.exec scoped to the command (target=gh), got %q", procExec) |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +func hasSubErr(errs []string, substr string) bool { |
| 154 | + for _, e := range errs { |
| 155 | + if strings.Contains(e, substr) { |
| 156 | + return true |
| 157 | + } |
| 158 | + } |
| 159 | + return false |
| 160 | +} |
0 commit comments