Skip to content

Commit 590bb9e

Browse files
committed
feat(share): implement share revoke command
Replace the share revoke stub with a working implementation. Resolves the share, fetches all members/invitations/external invitations, then searches for a match by email or ID. Single match dispatches to the appropriate delete method. Multiple matches return an ambiguity error listing all candidates and suggesting the user specify an ID. Main and photos shares are rejected early. Property tests verify unique-match and ambiguous-match behavior across randomly generated entity sets (200 total iterations). Assisted-by: Kiro <noreply@kiro.dev>
1 parent ec1cd2a commit 590bb9e

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

cmd/share/revoke.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package shareCmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/ProtonMail/go-proton-api"
9+
"github.com/major0/proton-cli/api/drive"
10+
driveClient "github.com/major0/proton-cli/api/drive/client"
11+
"github.com/major0/proton-cli/api/share"
12+
shareClient "github.com/major0/proton-cli/api/share/client"
13+
cli "github.com/major0/proton-cli/cmd"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func init() {
18+
shareRevokeCmd.RunE = runShareRevoke
19+
}
20+
21+
// revokeTarget identifies a matched entity for revocation.
22+
type revokeTarget struct {
23+
kind string // "member", "invitation", "external-invitation"
24+
id string // MemberID, InvitationID, or ExternalInvitationID
25+
desc string // human-readable description for ambiguity errors
26+
}
27+
28+
// findRevokeTarget searches members, invitations, and external invitations
29+
// for a match by email or ID. Returns the single match, or an error if
30+
// zero or multiple matches are found.
31+
func findRevokeTarget(arg string, members []share.Member, invs []share.Invitation, exts []share.ExternalInvitation) (revokeTarget, error) {
32+
var matches []revokeTarget
33+
34+
for _, m := range members {
35+
if m.Email == arg || m.MemberID == arg {
36+
matches = append(matches, revokeTarget{
37+
kind: "member",
38+
id: m.MemberID,
39+
desc: fmt.Sprintf("member %s (%s)", m.Email, m.MemberID),
40+
})
41+
}
42+
}
43+
44+
for _, inv := range invs {
45+
if inv.InviteeEmail == arg || inv.InvitationID == arg {
46+
matches = append(matches, revokeTarget{
47+
kind: "invitation",
48+
id: inv.InvitationID,
49+
desc: fmt.Sprintf("invitation %s (%s)", inv.InviteeEmail, inv.InvitationID),
50+
})
51+
}
52+
}
53+
54+
for _, ext := range exts {
55+
if ext.InviteeEmail == arg || ext.ExternalInvitationID == arg {
56+
matches = append(matches, revokeTarget{
57+
kind: "external-invitation",
58+
id: ext.ExternalInvitationID,
59+
desc: fmt.Sprintf("external invitation %s (%s)", ext.InviteeEmail, ext.ExternalInvitationID),
60+
})
61+
}
62+
}
63+
64+
switch len(matches) {
65+
case 0:
66+
return revokeTarget{}, fmt.Errorf("no matching member or invitation found")
67+
case 1:
68+
return matches[0], nil
69+
default:
70+
var descs []string
71+
for _, m := range matches {
72+
descs = append(descs, m.desc)
73+
}
74+
return revokeTarget{}, fmt.Errorf("ambiguous match — use a specific ID instead:\n %s", strings.Join(descs, "\n "))
75+
}
76+
}
77+
78+
func runShareRevoke(_ *cobra.Command, args []string) error {
79+
shareName := args[0]
80+
target := args[1]
81+
82+
ctx, cancel := context.WithTimeout(context.Background(), cli.Timeout)
83+
defer cancel()
84+
85+
session, err := cli.RestoreSession(ctx)
86+
if err != nil {
87+
return err
88+
}
89+
90+
dc, err := driveClient.NewClient(ctx, session)
91+
if err != nil {
92+
return err
93+
}
94+
95+
resolved, err := dc.ResolveShare(ctx, shareName, true)
96+
if err != nil {
97+
return fmt.Errorf("share revoke: %s: share not found", shareName)
98+
}
99+
100+
// Main and photos shares don't support member management.
101+
meta := resolved.Metadata()
102+
if meta.Type == proton.ShareTypeMain || meta.Type == drive.ShareTypePhotos {
103+
return fmt.Errorf("share revoke: %s: cannot revoke from %s share", shareName, drive.FormatShareType(meta.Type))
104+
}
105+
106+
sc := shareClient.NewClient(session)
107+
shareID := meta.ShareID
108+
109+
members, err := sc.ListMembers(ctx, shareID)
110+
if err != nil {
111+
return fmt.Errorf("share revoke: listing members: %w", err)
112+
}
113+
114+
invs, err := sc.ListInvitations(ctx, shareID)
115+
if err != nil {
116+
return fmt.Errorf("share revoke: listing invitations: %w", err)
117+
}
118+
119+
exts, err := sc.ListExternalInvitations(ctx, shareID)
120+
if err != nil {
121+
return fmt.Errorf("share revoke: listing external invitations: %w", err)
122+
}
123+
124+
match, err := findRevokeTarget(target, members, invs, exts)
125+
if err != nil {
126+
return fmt.Errorf("share revoke: %s: %w", target, err)
127+
}
128+
129+
switch match.kind {
130+
case "member":
131+
if err := sc.RemoveMember(ctx, shareID, match.id); err != nil {
132+
return fmt.Errorf("share revoke: %w", err)
133+
}
134+
fmt.Printf("Removed member %s\n", target)
135+
case "invitation":
136+
if err := sc.DeleteInvitation(ctx, shareID, match.id); err != nil {
137+
return fmt.Errorf("share revoke: %w", err)
138+
}
139+
fmt.Printf("Cancelled invitation for %s\n", target)
140+
case "external-invitation":
141+
if err := sc.DeleteExternalInvitation(ctx, shareID, match.id); err != nil {
142+
return fmt.Errorf("share revoke: %w", err)
143+
}
144+
fmt.Printf("Cancelled external invitation for %s\n", target)
145+
}
146+
147+
return nil
148+
}

cmd/share/revoke_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package shareCmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/major0/proton-cli/api/share"
9+
"pgregory.net/rapid"
10+
)
11+
12+
// genDistinctIDs generates n distinct non-empty string IDs.
13+
func genDistinctIDs(t *rapid.T, n int, label string) []string {
14+
seen := make(map[string]bool, n)
15+
ids := make([]string, 0, n)
16+
for len(ids) < n {
17+
id := fmt.Sprintf("%s-%d-%s", label, len(ids), rapid.StringMatching(`[a-z0-9]{4,8}`).Draw(t, label))
18+
if !seen[id] {
19+
seen[id] = true
20+
ids = append(ids, id)
21+
}
22+
}
23+
return ids
24+
}
25+
26+
// TestFindRevokeTarget_UniqueMatch_Property verifies that when a search
27+
// argument matches exactly one entity, findRevokeTarget returns it.
28+
//
29+
// **Property 3: Revoke Entity Lookup**
30+
// **Validates: Requirements 4.1, 4.5**
31+
func TestFindRevokeTarget_UniqueMatch_Property(t *testing.T) {
32+
rapid.Check(t, func(t *rapid.T) {
33+
nMembers := rapid.IntRange(0, 5).Draw(t, "nMembers")
34+
nInvs := rapid.IntRange(0, 5).Draw(t, "nInvs")
35+
nExts := rapid.IntRange(0, 5).Draw(t, "nExts")
36+
total := nMembers + nInvs + nExts
37+
if total == 0 {
38+
return // skip empty case
39+
}
40+
41+
// Generate distinct IDs and emails for all entities.
42+
allIDs := genDistinctIDs(t, total, "id")
43+
allEmails := genDistinctIDs(t, total, "email")
44+
45+
idx := 0
46+
members := make([]share.Member, nMembers)
47+
for i := range members {
48+
members[i] = share.Member{MemberID: allIDs[idx], Email: allEmails[idx]}
49+
idx++
50+
}
51+
invs := make([]share.Invitation, nInvs)
52+
for i := range invs {
53+
invs[i] = share.Invitation{InvitationID: allIDs[idx], InviteeEmail: allEmails[idx]}
54+
idx++
55+
}
56+
exts := make([]share.ExternalInvitation, nExts)
57+
for i := range exts {
58+
exts[i] = share.ExternalInvitation{ExternalInvitationID: allIDs[idx], InviteeEmail: allEmails[idx]}
59+
idx++
60+
}
61+
62+
// Pick a random entity and search by its email.
63+
pickIdx := rapid.IntRange(0, total-1).Draw(t, "pickIdx")
64+
var searchArg, wantKind, wantID string
65+
if pickIdx < nMembers {
66+
searchArg = members[pickIdx].Email
67+
wantKind = "member"
68+
wantID = members[pickIdx].MemberID
69+
} else if pickIdx < nMembers+nInvs {
70+
i := pickIdx - nMembers
71+
searchArg = invs[i].InviteeEmail
72+
wantKind = "invitation"
73+
wantID = invs[i].InvitationID
74+
} else {
75+
i := pickIdx - nMembers - nInvs
76+
searchArg = exts[i].InviteeEmail
77+
wantKind = "external-invitation"
78+
wantID = exts[i].ExternalInvitationID
79+
}
80+
81+
got, err := findRevokeTarget(searchArg, members, invs, exts)
82+
if err != nil {
83+
t.Fatalf("findRevokeTarget(%q): %v", searchArg, err)
84+
}
85+
if got.kind != wantKind {
86+
t.Fatalf("kind = %q, want %q", got.kind, wantKind)
87+
}
88+
if got.id != wantID {
89+
t.Fatalf("id = %q, want %q", got.id, wantID)
90+
}
91+
})
92+
}
93+
94+
// TestFindRevokeTarget_Ambiguous_Property verifies that when a search
95+
// argument matches entities in multiple lists, an ambiguity error is returned.
96+
func TestFindRevokeTarget_Ambiguous_Property(t *testing.T) {
97+
rapid.Check(t, func(t *rapid.T) {
98+
// Create a member and an invitation with the same email.
99+
sharedEmail := fmt.Sprintf("shared-%s@test.local", rapid.StringMatching(`[a-z]{4}`).Draw(t, "email"))
100+
101+
members := []share.Member{{MemberID: "m1", Email: sharedEmail}}
102+
invs := []share.Invitation{{InvitationID: "inv1", InviteeEmail: sharedEmail}}
103+
104+
_, err := findRevokeTarget(sharedEmail, members, invs, nil)
105+
if err == nil {
106+
t.Fatal("expected ambiguity error, got nil")
107+
}
108+
if !strings.Contains(err.Error(), "ambiguous") {
109+
t.Fatalf("expected ambiguity error, got: %v", err)
110+
}
111+
})
112+
}
113+
114+
// TestFindRevokeTarget_NoMatch verifies that a non-matching argument
115+
// returns a "no matching" error.
116+
func TestFindRevokeTarget_NoMatch(t *testing.T) {
117+
members := []share.Member{{MemberID: "m1", Email: "alice@test.local"}}
118+
_, err := findRevokeTarget("nobody@test.local", members, nil, nil)
119+
if err == nil {
120+
t.Fatal("expected error, got nil")
121+
}
122+
if !strings.Contains(err.Error(), "no matching") {
123+
t.Fatalf("expected 'no matching' error, got: %v", err)
124+
}
125+
}
126+
127+
// TestFindRevokeTarget_ByID verifies lookup by ID (not email).
128+
func TestFindRevokeTarget_ByID(t *testing.T) {
129+
members := []share.Member{{MemberID: "m1", Email: "alice@test.local"}}
130+
got, err := findRevokeTarget("m1", members, nil, nil)
131+
if err != nil {
132+
t.Fatalf("findRevokeTarget by ID: %v", err)
133+
}
134+
if got.kind != "member" || got.id != "m1" {
135+
t.Fatalf("unexpected result: %+v", got)
136+
}
137+
}

0 commit comments

Comments
 (0)