Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
c5273e6
fix: carry CLM href via ExternalId instead of profile for Grant/Revoke
FeliLucero1 Aug 7, 2026
9c672a4
fix: carry CLM href via a raw annotation instead of deprecated Extern…
FeliLucero1 Aug 7, 2026
dcef0bb
fix: derive CLM group/member hrefs from ID instead of resource metadata
FeliLucero1 Aug 7, 2026
59dba8a
fix: prefer real server-issued hrefs over derived ones on writes
FeliLucero1 Aug 10, 2026
43d07cf
fix: address deep-code-review findings on PR #63
FeliLucero1 Aug 10, 2026
6e7b5b5
fix: address latest bot review findings on PR #63
FeliLucero1 Aug 10, 2026
d544828
doc: confirm clmHrefWithID's path-prefix reuse against clm_models.go
FeliLucero1 Aug 10, 2026
08f151d
doc: confirm isOptInFeatureUnavailableError's NotFound rationale agai…
FeliLucero1 Aug 11, 2026
a56ca85
fix: reject bare scheme+host samples in clmHrefWithID; preserve annos…
FeliLucero1 Aug 11, 2026
949672e
fix: address remaining PR #63 review threads
FeliLucero1 Aug 11, 2026
42db4b8
test: make the sample-branch Grant test actually pin sample-preference
FeliLucero1 Aug 11, 2026
fa794e7
fix: validate newID and log unexpected sample-parse failures in clmHr…
FeliLucero1 Aug 12, 2026
07e8d06
refactor: dedupe GroupHref/MemberHref, sampleHrefs construction, and …
FeliLucero1 Aug 12, 2026
5363992
test: make the folder-security SampleBranch test actually pin sample-…
FeliLucero1 Aug 12, 2026
b333dbe
fix: address 3 findings from the latest automated review pass
FeliLucero1 Aug 12, 2026
a71e90c
fix: SetGroupHref fails loudly on a typo'd ID, matching its new siblings
FeliLucero1 Aug 12, 2026
699f1f1
fix: address 3 more findings from the automated review pass
FeliLucero1 Aug 12, 2026
2ad413c
fix: also exclude empty entry hrefs in clmSampleHrefsFrom
FeliLucero1 Aug 12, 2026
d89cf73
fix: close clmPreferredHref's empty-id gap, fix stale Grant comment
FeliLucero1 Aug 13, 2026
672b2d0
fix: address bot review findings on PR #63
FeliLucero1 Aug 13, 2026
46655a3
fix: reject empty principal ID in Grant, matching Revoke's guard
FeliLucero1 Aug 13, 2026
c3f07fe
fix: reject empty member/group ID in clmGroupBuilder Grant/Revoke
FeliLucero1 Aug 13, 2026
e1d1c66
fix: reject empty folder ID in clmFolderBuilder Grant/Revoke
FeliLucero1 Aug 13, 2026
af12f73
fix: use codes.InvalidArgument for provisioning validation errors; re…
FeliLucero1 Aug 13, 2026
124bef5
fix: use codes.InvalidArgument for remaining plain-Errorf validation …
FeliLucero1 Aug 13, 2026
1165916
fix: derive sibling hrefs from the parsed URL path, not the raw string
FeliLucero1 Aug 13, 2026
d0f44d7
fix: serialize CI jobs that hit the shared DocuSign demo account
FeliLucero1 Aug 18, 2026
1a72aec
fix: log clmPreferredHref's no-samples fallback at Debug
FeliLucero1 Aug 18, 2026
a4c7cb8
fix: silence staticcheck SA5011 false positives in clm_folders_test.go
FeliLucero1 Aug 19, 2026
71407ba
fix: strip query/fragment from clmHrefWithID's derived href
FeliLucero1 Aug 19, 2026
e395125
fix: propagate getAnnos in default branches, fix stale clmIDFromHref doc
FeliLucero1 Aug 19, 2026
004b78b
fix: request impersonation scope for CLM, parse CLM's real error enve…
FeliLucero1 Aug 20, 2026
fd579b2
fix: Message() empty-UserMessage fallback, stale impersonation-scope doc
FeliLucero1 Aug 20, 2026
a090a70
fix: revert impersonation scope — confirmed JWT-Grant-only, not the r…
FeliLucero1 Aug 20, 2026
c4416b2
fix: rework SearchFolders on CLM's real async Task API, fix Security …
FeliLucero1 Aug 20, 2026
aec02e0
fix: rework PatchFolderSecurity on CLM's ChangeSecurityTasks endpoint
FeliLucero1 Aug 21, 2026
67f0a13
fix: nest ChangeSecurityTasks' folder+security under Folder, not top-…
FeliLucero1 Aug 21, 2026
07d0337
docs: align SearchFolders comments with CLM Folders:Search reference
FeliLucero1 Aug 21, 2026
c3e7d3a
chore: sync baton-sdk to v0.24.6 to match main
FeliLucero1 Aug 21, 2026
749f98f
chore: leave .versions.yaml on baton-admin pin for this branch
FeliLucero1 Aug 21, 2026
0cdf026
merge: main into clm-folder stack to pick up baton-sdk v0.24.6
FeliLucero1 Aug 21, 2026
28a362f
fix: fail loud when folder-search Result.Href is empty mid-pagination
FeliLucero1 Aug 21, 2026
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ on:
push:
branches:
- main
# Workflow-level (not per-job): all three jobs below hit the same shared DocuSign demo
# account, and running two runs' Grant/Revoke cycles concurrently races on that account's
# real state (one run's mid-cycle Grant/Revoke can make another run's "should be zero
# grants after Revoke" assertion fail). A per-job concurrency block only protects a
# *running* job from cancellation — GitHub Actions still cancels a *pending* job in the
# same group when a newer one queues. Declaring it once here makes the whole
# needs-chained run (all three jobs) queue/cancel as one unit against the shared group.
concurrency:
group: docusign-demo-account
cancel-in-progress: false
Comment on lines +15 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Confidence: high (non-blocking, accepted-tradeoff territory). cancel-in-progress: false prevents cancelling a running run, but GitHub still only keeps one pending run per concurrency group — when a third run queues, the previously pending one is cancelled. Because the group is a constant shared by every PR and every push: main, a PR's CI can be cancelled before it ever starts (and reported as cancelled on the PR) whenever two other runs are ahead of it, which is a real cost on a busy day. If that's acceptable, no change needed; otherwise consider noting the manual-rerun expectation in the comment, since the comment currently reads as if moving to workflow level removes the pending-cancellation behaviour rather than just changing its granularity from job to run.

Comment thread
FeliLucero1 marked this conversation as resolved.
jobs:
test-groups:
runs-on: ubuntu-latest
Expand Down
345 changes: 293 additions & 52 deletions pkg/client/clm_client.go

Large diffs are not rendered by default.

101 changes: 87 additions & 14 deletions pkg/client/clm_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package client_test

import (
"context"
"strings"
"testing"
"time"

"github.com/conductorone/baton-docusign/pkg/client"
"github.com/conductorone/baton-docusign/pkg/client/clmtest"
Expand Down Expand Up @@ -31,12 +33,83 @@ func TestSearchFolders_Pagination(t *testing.T) {
}
// Search results are summaries — no Security field.
for _, f := range all {
if len(f.Security.Groups.Items) != 0 || len(f.Security.Roles.Items) != 0 || len(f.Security.Users.Items) != 0 {
if len(f.Security.Groups) != 0 || len(f.Security.Roles) != 0 || len(f.Security.Users) != 0 {
t.Errorf("folder %s: expected Search to omit Security, got %+v", f.Name, f.Security)
}
}
}

// TestSearchFolders_EmptyResultHrefFailsLoud is a regression for the page-1 loop that
// happens when Result.Href is empty but more pages remain: getClmNextToken would mint a
// token with ResultHref:"", and the next SearchFolders call would re-POST a new search
// (resetting Requests) forever. SearchFolders must error instead of emitting that token.
func TestSearchFolders_EmptyResultHrefFailsLoud(t *testing.T) {
srv, c := clmtest.NewServer(t)
ctx := context.Background()
srv.SetOmitFolderSearchResultHref(true)

folders, next, _, err := c.SearchFolders(ctx, client.PageOptions{PageSize: 2})
if err == nil {
t.Fatalf("expected error when Result.Href is empty and more pages remain, got folders=%d next=%q", len(folders), next)
}
if next != "" {
t.Errorf("expected empty next token on failure, got %q", next)
}
if !strings.Contains(err.Error(), "Result has no Href") {
t.Errorf("expected Result-Href error, got: %v", err)
}
}

// TestSearchFolders_PollsUntilSuccess is a regression test for SearchFolders'
// awaitClmFolderSearchTask branch: every live test against a real CLM tenant resolved
// the task inline (Status "Success" already in the POST response), leaving the polling
// branch itself unexercised until now.
func TestSearchFolders_PollsUntilSuccess(t *testing.T) {
original := client.ClmFolderSearchTaskPollInterval
client.ClmFolderSearchTaskPollInterval = time.Millisecond
defer func() { client.ClmFolderSearchTaskPollInterval = original }()

srv, c := clmtest.NewServer(t)
ctx := context.Background()

srv.SetPendingFolderSearchPolls(2)

folders, _, _, err := c.SearchFolders(ctx, client.PageOptions{PageSize: 10})
if err != nil {
t.Fatalf("SearchFolders: %v", err)
}
if len(folders) != 3 {
t.Fatalf("expected all 3 seeded folders once the task resolves, got %d", len(folders))
}
}

// TestPatchFolderSecurity_PollsUntilSuccess is a regression test for
// PatchFolderSecurity's awaitClmChangeSecurityTask branch — unverified against a live
// tenant (see PatchFolderSecurity's doc in clm_client.go), so this mock-driven test is
// this branch's only coverage.
func TestPatchFolderSecurity_PollsUntilSuccess(t *testing.T) {
original := client.ClmFolderSearchTaskPollInterval
client.ClmFolderSearchTaskPollInterval = time.Millisecond
defer func() { client.ClmFolderSearchTaskPollInterval = original }()

srv, c := clmtest.NewServer(t)
ctx := context.Background()

groupHref := srv.GroupHref("group-ops")
srv.SetPendingChangeSecurityPolls(2)

if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{
Groups: []client.ClmGroupSecurityEntry{{AccessType: client.ClmAccessTypeView, Href: groupHref}},
}); err != nil {
t.Fatalf("PatchFolderSecurity: %v", err)
}

sec := srv.FolderSecurity("folder-templates")
if len(sec.Groups) != 1 || sec.Groups[0].AccessType != client.ClmAccessTypeView || sec.Groups[0].Href != groupHref {
t.Fatalf("expected one View entry for %s once the task resolves, got %+v", groupHref, sec.Groups)
}
}

func TestGetFolder_ExpandSecurity(t *testing.T) {
_, c := clmtest.NewServer(t)
ctx := context.Background()
Expand All @@ -46,7 +119,7 @@ func TestGetFolder_ExpandSecurity(t *testing.T) {
if err != nil {
t.Fatalf("GetFolder: %v", err)
}
if len(folder.Security.Groups.Items) != 0 || len(folder.Security.Roles.Items) != 0 || len(folder.Security.Users.Items) != 0 {
if len(folder.Security.Groups) != 0 || len(folder.Security.Roles) != 0 || len(folder.Security.Users) != 0 {
t.Errorf("expected no Security without ?expand=Security, got %+v", folder.Security)
}
})
Expand All @@ -56,14 +129,14 @@ func TestGetFolder_ExpandSecurity(t *testing.T) {
if err != nil {
t.Fatalf("GetFolder: %v", err)
}
if len(folder.Security.Groups.Items) != 2 {
t.Fatalf("expected 2 seeded group security entries, got %d: %+v", len(folder.Security.Groups.Items), folder.Security.Groups.Items)
if len(folder.Security.Groups) != 2 {
t.Fatalf("expected 2 seeded group security entries, got %d: %+v", len(folder.Security.Groups), folder.Security.Groups)
}
if len(folder.Security.Roles.Items) != 1 {
t.Fatalf("expected 1 seeded role security entry, got %d: %+v", len(folder.Security.Roles.Items), folder.Security.Roles.Items)
if len(folder.Security.Roles) != 1 {
t.Fatalf("expected 1 seeded role security entry, got %d: %+v", len(folder.Security.Roles), folder.Security.Roles)
}
if len(folder.Security.Users.Items) != 1 {
t.Fatalf("expected 1 seeded user security entry, got %d: %+v", len(folder.Security.Users.Items), folder.Security.Users.Items)
if len(folder.Security.Users) != 1 {
t.Fatalf("expected 1 seeded user security entry, got %d: %+v", len(folder.Security.Users), folder.Security.Users)
}
})

Expand Down Expand Up @@ -93,8 +166,8 @@ func TestPatchFolderSecurity_SendsExactEntries(t *testing.T) {
}

sec := srv.FolderSecurity("folder-templates")
if len(sec.Groups.Items) != 1 || sec.Groups.Items[0].AccessType != client.ClmAccessTypeView || sec.Groups.Items[0].Href != groupHref {
t.Fatalf("expected one View entry for %s, got %+v", groupHref, sec.Groups.Items)
if len(sec.Groups) != 1 || sec.Groups[0].AccessType != client.ClmAccessTypeView || sec.Groups[0].Href != groupHref {
t.Fatalf("expected one View entry for %s, got %+v", groupHref, sec.Groups)
}

// Sending a single-entry Groups list for the same Href again replaces the prior entry.
Expand All @@ -105,11 +178,11 @@ func TestPatchFolderSecurity_SendsExactEntries(t *testing.T) {
}

sec = srv.FolderSecurity("folder-templates")
if len(sec.Groups.Items) != 1 {
t.Fatalf("expected the existing entry to be updated in place, not duplicated: %+v", sec.Groups.Items)
if len(sec.Groups) != 1 {
t.Fatalf("expected the existing entry to be updated in place, not duplicated: %+v", sec.Groups)
}
if sec.Groups.Items[0].AccessType != client.ClmAccessTypeNoAccess {
t.Errorf("expected AccessType NoAccess after revoke, got %q", sec.Groups.Items[0].AccessType)
if sec.Groups[0].AccessType != client.ClmAccessTypeNoAccess {
t.Errorf("expected AccessType NoAccess after revoke, got %q", sec.Groups[0].AccessType)
}
}

Expand Down
28 changes: 22 additions & 6 deletions pkg/client/clm_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ func preparePagedRequestClm(baseURL *url.URL, endpoint string, options PageOptio
return nil, clmRequestedPage{}, fmt.Errorf("baton-docusign: invalid CLM endpoint: %w", err)
}

fullURL := baseURL.ResolveReference(endpointURL)
return appendClmPageQuery(baseURL.ResolveReference(endpointURL), options)
}

// appendClmPageQuery appends CLM's pageSortParams.offset/limit query params to an
// already-resolved absolute URL and decodes options.PageToken — the part of
// preparePagedRequestClm that doesn't depend on resolving a relative endpoint against
// the CLM base URL. Split out for SearchFolders' continuation pages, which paginate
// against a server-issued Result href (a per-search URL CLM hands back, not one of this
// package's own static endpoint constants) rather than a fixed collection endpoint.
func appendClmPageQuery(fullURL *url.URL, options PageOptions) (*url.URL, clmRequestedPage, error) {
q := fullURL.Query()

offset := 0
Expand All @@ -58,10 +67,15 @@ func preparePagedRequestClm(baseURL *url.URL, endpoint string, options PageOptio

// clmPageToken is the internal offset-based continuation token for CLM pagination.
// Requests counts how many requests this pagination sequence has made so far — see
// maxClmListPages.
// maxClmListPages. ResultHref is only set by SearchFolders' continuation pages: unlike
// every other CLM list endpoint (a fixed collection URL re-queried with a different
// offset), a folder search's results live at a per-search URL CLM hands back from the
// FolderSearchTasks create call, so the token must carry it forward — the collection
// URL isn't otherwise derivable from the resource type alone.
type clmPageToken struct {
Offset int `json:"offset"`
Requests int `json:"requests"`
Offset int `json:"offset"`
Requests int `json:"requests"`
ResultHref string `json:"resultHref,omitempty"`
}

func encodeClmPageToken(pt *clmPageToken) string {
Expand Down Expand Up @@ -171,7 +185,9 @@ func decodeClmPageToken(token string) (*clmPageToken, error) {
// matters when the floor is the larger of the two estimates.
const maxClmListPages = 1000

func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, total int) (string, error) {
// resultHref is embedded in the returned token as-is (see clmPageToken's doc) — pass ""
// for every endpoint except SearchFolders' continuation pages.
func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, total int, resultHref string) (string, error) {
if itemCount == 0 {
return "", nil
}
Expand All @@ -195,5 +211,5 @@ func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, to
return "", fmt.Errorf("baton-docusign: exceeded %d pages paginating a CLM list — the API may be ignoring the requested offset", maxClmListPages)
}

return encodeClmPageToken(&clmPageToken{Offset: nextOffset, Requests: nextRequests}), nil
return encodeClmPageToken(&clmPageToken{Offset: nextOffset, Requests: nextRequests, ResultHref: resultHref}), nil
}
12 changes: 6 additions & 6 deletions pkg/client/clm_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestGetClmNextToken_ComputesFromRequestNotResponse(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := getClmNextToken(tt.requested, tt.itemCount, tt.hasNext, tt.total)
got, err := getClmNextToken(tt.requested, tt.itemCount, tt.hasNext, tt.total, "")
if err != nil {
t.Fatalf("getClmNextToken: %v", err)
}
Expand Down Expand Up @@ -78,7 +78,7 @@ func TestGetClmNextToken_ComputesFromRequestNotResponse(t *testing.T) {
// multiple of the page size, a full page landing exactly on Total stops immediately —
// no need to wait for an empty page in this case, since Total confirms it.
func TestGetClmNextToken_ExactBoundaryDoesNotLoop(t *testing.T) {
got, err := getClmNextToken(clmRequestedPage{Offset: 100, PageSize: 100}, 100, false, 200)
got, err := getClmNextToken(clmRequestedPage{Offset: 100, PageSize: 100}, 100, false, 200, "")
if err != nil {
t.Fatalf("getClmNextToken: %v", err)
}
Expand Down Expand Up @@ -113,15 +113,15 @@ func TestGetClmNextToken_ExactBoundaryDoesNotLoop(t *testing.T) {
func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) {
t.Run("reaches the cap through normal advancement", func(t *testing.T) {
requested := clmRequestedPage{Offset: (maxClmListPages - 1) * 100, PageSize: 100}
_, err := getClmNextToken(requested, 100, false, 0)
_, err := getClmNextToken(requested, 100, false, 0, "")
if err == nil {
t.Fatal("expected an error once maxClmListPages is reached, got nil")
}
})

t.Run("does not fire just below the cap", func(t *testing.T) {
requested := clmRequestedPage{Offset: (maxClmListPages - 2) * 100, PageSize: 100}
got, err := getClmNextToken(requested, 100, false, 0)
got, err := getClmNextToken(requested, 100, false, 0, "")
if err != nil {
t.Fatalf("expected no error just below the cap, got: %v", err)
}
Expand All @@ -134,7 +134,7 @@ func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) {
// Offset alone, with Requests at its zero value (as a pre-cap or
// round-tripped token would decode to), must still trigger the cap.
requested := clmRequestedPage{Offset: maxClmListPages * 100, PageSize: 100}
_, err := getClmNextToken(requested, 100, false, 0)
_, err := getClmNextToken(requested, 100, false, 0, "")
if err == nil {
t.Fatal("expected the cap to fire from Offset alone, got nil error")
}
Expand All @@ -147,7 +147,7 @@ func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) {
// requested.Requests to reach the same cap. Set Requests to exactly one below
// the cap to isolate that it alone is what trips it here.
requested := clmRequestedPage{Offset: maxClmListPages - 1, PageSize: 100, Requests: maxClmListPages - 1}
_, err := getClmNextToken(requested, 1, true, 0)
_, err := getClmNextToken(requested, 1, true, 0, "")
if err == nil {
t.Fatal("expected the request-count estimate to trip the cap even though the offset floor would not have")
}
Expand Down
Loading
Loading