Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/arc/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,38 @@ func runShareCreate(cmd *cobra.Command, args []string) error {
return nil
}

// shareListEntry is the shape emitted by `arc share list --json`.
// Edit tokens are deliberately excluded — they're bearer secrets that
// belong in the keyring, not in machine-readable output.
type shareListEntry struct {
ID string `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
KeyB64Url string `json:"key_b64url,omitempty"`
PlanFile string `json:"plan_file,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

func runShareList(cmd *cobra.Command, args []string) error {
f, err := sharesconfig.Load()
if err != nil {
return err
}
if outputJSON {
entries := make([]shareListEntry, 0, len(f.Shares))
for _, s := range f.Shares {
entries = append(entries, shareListEntry{
ID: s.ID,
Kind: s.Kind,
URL: s.URL,
KeyB64Url: s.KeyB64Url,
PlanFile: s.PlanFile,
CreatedAt: s.CreatedAt,
})
}
outputResult(entries)
return nil
}
if len(f.Shares) == 0 {
fmt.Println("(no shares)")
return nil
Expand Down
99 changes: 99 additions & 0 deletions cmd/arc/share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,3 +881,102 @@ func writeShareServerConfig(t *testing.T, server string) {
t.Fatalf("write config: %v", err)
}
}

// TestRunShareListJSON locks in `arc share list --json` emitting a JSON
// array of share entries (vs the default tab-separated text). Regression
// guard: this command silently ignored the global --json flag prior to
// this test landing.
func TestRunShareListJSON(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := startTestPasteServer(t)
defer srv.Close()

// Seed two shares via the keyring so the list output has multiple rows.
must := func(err error) {
t.Helper()
if err != nil {
t.Fatalf("seed share: %v", err)
}
}
must(sharesconfig.Add(sharesconfig.Share{
ID: "abc12345",
Kind: "local",
URL: "http://localhost:7432/share/abc12345#k=key1",
KeyB64Url: "key1",
EditToken: "tok1",
PlanFile: "docs/plans/a.md",
}))
must(sharesconfig.Add(sharesconfig.Share{
ID: "def67890",
Kind: "shared",
URL: "https://arcplanner.sentiolabs.io/share/def67890#k=key2",
KeyB64Url: "key2",
EditToken: "tok2",
PlanFile: "docs/plans/b.md",
}))

// Force the global --json flag on for this test, restore after.
prev := outputJSON
outputJSON = true
t.Cleanup(func() { outputJSON = prev })

out := captureStdout(t, func() {
if err := runShareList(shareListCmd, nil); err != nil {
t.Fatalf("runShareList: %v", err)
}
})

// Output must parse as a JSON array (not the legacy tab-separated text).
var got []sharesconfig.Share
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("output is not valid JSON array: %v\noutput: %q", err, out)
}
if len(got) != 2 {
t.Fatalf("expected 2 shares, got %d: %+v", len(got), got)
}
ids := map[string]bool{got[0].ID: true, got[1].ID: true}
if !ids["abc12345"] || !ids["def67890"] {
t.Errorf("expected both seeded share IDs in output, got: %+v", got)
}

// Edit tokens are bearer secrets gated behind the Author URL — they
// must NOT appear in the JSON list output. The list view exposes
// id/kind/url/plan_file/created_at, matching the parity of the text
// columns plus a timestamp. (Decryption keys travel inside the URL
// fragment, the same way they do in the text output, so excluding
// them from --json would produce inconsistent surface area.)
for _, secret := range []string{"tok1", "tok2"} {
if strings.Contains(out, secret) {
t.Errorf("edit_token %q leaked in --json output: %s", secret, out)
}
}
}

// TestRunShareListJSONEmpty verifies that `arc share list --json` with no
// shares returns an empty JSON array rather than the human-friendly
// "(no shares)" string, so JSON consumers can `[].length` safely.
func TestRunShareListJSONEmpty(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := startTestPasteServer(t)
defer srv.Close()

prev := outputJSON
outputJSON = true
t.Cleanup(func() { outputJSON = prev })

out := captureStdout(t, func() {
if err := runShareList(shareListCmd, nil); err != nil {
t.Fatalf("runShareList: %v", err)
}
})

var got []sharesconfig.Share
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("output is not valid JSON array: %v\noutput: %q", err, out)
}
if len(got) != 0 {
t.Errorf("expected empty array, got %+v", got)
}
}
Loading