From 733df3fc04863301f1aff2a1617e6bc10832f8d6 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Tue, 15 Sep 2026 19:35:08 +0000 Subject: [PATCH 1/3] feat(cli): add server-side pagination - Pages platform-domain and social-provider resolution via admin list APIs - Resolves DNS zones by paging ListZonesPage instead of the first page - Passes paging through API-key service listing - Bumps ipfs-sdk, portal-sdk, and pinner to the paged list releases - Regenerates the API-key service mock for the paginated signature --- go.mod | 6 +-- go.sum | 22 +++------ internal/cli/account_api_keys.go | 4 +- internal/cli/account_api_keys_test.go | 16 +++---- internal/cli/admin_platform_domains_test.go | 8 ++-- internal/cli/catalog_admin_wiring.go | 50 ++++++++++++++++++++- internal/cli/dns.go | 28 +++++++++--- internal/cli/dns_test.go | 25 +++++++++++ internal/cli/ipns_service_test.go | 8 ++++ internal/cli/mock_APIKeyService.go | 40 +++++++++++------ 10 files changed, 151 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index bcc745cf..dda0b9ab 100644 --- a/go.mod +++ b/go.mod @@ -36,15 +36,15 @@ require ( go.lumeweb.com/canimcp v0.0.0-20260912112527-1c27fd5becdd go.lumeweb.com/fieldcraft v0.0.0-20260907095025-2a7bfdd040cb go.lumeweb.com/ipfs-content v0.1.18 - go.lumeweb.com/ipfs-sdk v0.1.98 + go.lumeweb.com/ipfs-sdk v0.1.99 go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 go.lumeweb.com/mcpcanvas v0.0.0-20260907112052-7adbd4d8dcac go.lumeweb.com/mcpforge v0.0.0-20260907155938-f64f92060f4e go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8 go.lumeweb.com/oauth v0.1.6 go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 - go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 - go.lumeweb.com/portal-sdk v0.1.73 + go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799 + go.lumeweb.com/portal-sdk v0.1.74 go.lumeweb.com/queryutil v0.3.19 go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae go.lumeweb.com/tunneler/cloudflare v0.0.0-20260907123602-56944ca7aeae diff --git a/go.sum b/go.sum index 17daa035..039a8dc2 100644 --- a/go.sum +++ b/go.sum @@ -739,8 +739,8 @@ go.lumeweb.com/gswagger v0.20.12 h1:FNSrDAeMQVAGIogzJ4SQX+X8U/FMeWQ80yxdyfsM2/M= go.lumeweb.com/gswagger v0.20.12/go.mod h1:qz+9/8qstYy7WJ48VZJW/0FonlExw/Igv7vWGgp5uoU= go.lumeweb.com/ipfs-content v0.1.18 h1:Lq3/A+2kZcS4RK+bpdk+m4iJ95ZgpEa4DKhiH6bQWUg= go.lumeweb.com/ipfs-content v0.1.18/go.mod h1:idWCsfndMDCsE5LtU0ZBNaFTBAaiuIe50dce0SDJLxw= -go.lumeweb.com/ipfs-sdk v0.1.98 h1:iiXIllSx2ylSlz5BkofFxzRjqHZXWDHnMGnSbNxU5AI= -go.lumeweb.com/ipfs-sdk v0.1.98/go.mod h1:WMlp/mBGeguV5tmUYd+Z2MkPrJczZOPhqnK/BLUcBGU= +go.lumeweb.com/ipfs-sdk v0.1.99 h1:+suUmUwlGvyT2Yru/XscPbPL1BhJno6/7l3UkJ8Vqrs= +go.lumeweb.com/ipfs-sdk v0.1.99/go.mod h1:mNTAVzCrDUfT8uliHXH5vlAE6z8EzjnpMKdgMTTgfbA= go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 h1:RTBD+zoXHOYYKreNRoQhyLLKKWp/VgYfr7qojCzKjww= go.lumeweb.com/ipfs-sdk/dnsname v0.1.64/go.mod h1:1++6EWMiG/BC5UQjlIobId2YcWDVHjHEhdsEzeISKpQ= go.lumeweb.com/mcpcanvas v0.0.0-20260907112052-7adbd4d8dcac h1:3IF4iIQABGV2Q4KMwTKOkmiG3DBnZygQuMj3VqCCn7E= @@ -753,26 +753,16 @@ go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg= go.lumeweb.com/oauth v0.1.6/go.mod h1:Bfdxi1gkv+Ypj9yj9uOSmKnbZX8C7q7Pyn1OdPyuCbM= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 h1:kb2adBZ8Ed11yuXoQw2I8LfmbPjpPkc0NPwyShKJ5KE= go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9/go.mod h1:DbO27Lr6pBSzC+HogQMNNVEZMvkAHYwckL7i79aTETM= -go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af h1:oi/hQ2cYZaKpl3SHGbPbVSj7waf0CcNKNIzPboaAvzY= -go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= -go.lumeweb.com/pinner v0.0.0-20260913165141-91bcd5ac166f h1:oM/B5k2iWw+rPwYO6d1Cw6PbCJ5eBLWnXib4zOLDTYg= -go.lumeweb.com/pinner v0.0.0-20260913165141-91bcd5ac166f/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= -go.lumeweb.com/pinner v0.0.0-20260913165231-607910a86128 h1:FHM7hyCcAfkLsAEMtNWFL+adNrTxS+4rRIV4EonNdiw= -go.lumeweb.com/pinner v0.0.0-20260913165231-607910a86128/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= -go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743 h1:R3GNQ7ENa2IGbaHJWvB5iWS0QkI/f4s6+zLzsD+aXts= -go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= -go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb h1:Cw0gjgj5auBFpEhnLj0YWRhNOoHCLiTQFMrqur74Wlw= -go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= -go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 h1:QsMQcXyrRXZoO0E5PPbFaDLMJMpfh6B5K30f35hxZ9M= -go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= +go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799 h1:jSkhXZgG2xmZpsa5toP3Xu44w5HFvff/wws77x22oO0= +go.lumeweb.com/pinner v0.0.0-20260915191542-82fb73a71799/go.mod h1:f8uj7cmOeJW79CWhBAwmva5Lh8xSLj5BRjjUbNFbpls= go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8= go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk= go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c= go.lumeweb.com/portal-middleware v0.3.7/go.mod h1:Yn9ZsFy5n3x2jUJ085M9B/aoZ+ANQV5QD/MAE0BpJXo= go.lumeweb.com/portal-router v0.7.7 h1:6mUGkG2BtHDygIPqRZs1sRkPLkz9l2wTXMUhZmdp24E= go.lumeweb.com/portal-router v0.7.7/go.mod h1:hl51sHDKcgw8yxJ6RraYBOI1Vnufq33FhKwfKiqCkVg= -go.lumeweb.com/portal-sdk v0.1.73 h1:rUbkoQlCtWEwdWEx4ha5zAKP9czQqlfAiQbX+EWmDzM= -go.lumeweb.com/portal-sdk v0.1.73/go.mod h1:4TPf4QRYC0U5VDxo4BpL+ifkq4JMioysB8J/c2WFaEs= +go.lumeweb.com/portal-sdk v0.1.74 h1:pg1i4xCZeXBAL2opNilCv9Uos+X+Cy1y4B/5o1m4188= +go.lumeweb.com/portal-sdk v0.1.74/go.mod h1:+ZD06oI+NjP6JCIBUZAr0fEkGu20WigCysMC2ITwIj4= go.lumeweb.com/queryutil v0.3.19 h1:GV+zKsyGJ+YYPZi0mqOocDAROCQruGdizrE8Aw8cNFk= go.lumeweb.com/queryutil v0.3.19/go.mod h1:YPrXdEelsjJNBPXrG4IiYp4aq5YUk/l4uXw8z2cSAuw= go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae h1:t2jwV1pyuUJb1Xbbz63JqcWrW2Jza52AU9AKP1E9BxE= diff --git a/internal/cli/account_api_keys.go b/internal/cli/account_api_keys.go index 012e5f9d..ff14c8f8 100644 --- a/internal/cli/account_api_keys.go +++ b/internal/cli/account_api_keys.go @@ -20,7 +20,7 @@ func accountAPIKeysList(ctx context.Context, cmd flagGetter, output Output, cfgM svc := svcFactory(authService, authToken) search := cmd.String(FlagSearch) - keys, total, err := svc.ListAPIKeys(ctx, search) + keys, total, err := svc.ListAPIKeys(ctx, search, 0, 0) if err != nil { return fmt.Errorf("failed to list API keys: %w", err) } @@ -108,7 +108,7 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu currentUUID := svc.GetCurrentAPIKeyUUID() resolvedID := idOrName if currentUUID != "" && !isUUIDString(idOrName) { - keys, _, listErr := svc.ListAPIKeys(ctx, idOrName) + keys, _, listErr := svc.ListAPIKeys(ctx, idOrName, 0, 0) if listErr == nil { for _, key := range keys { if key.Name == idOrName { diff --git a/internal/cli/account_api_keys_test.go b/internal/cli/account_api_keys_test.go index 475bfb07..4559fb37 100644 --- a/internal/cli/account_api_keys_test.go +++ b/internal/cli/account_api_keys_test.go @@ -87,7 +87,7 @@ func TestAPIKeyService_ListAPIKeys(t *testing.T) { t.Run(tt.name, func(t *testing.T) { svc := setupAPIKeyServiceWithAuth(t, "test-token", tt.setupAcc) - keys, total, err := svc.ListAPIKeys(context.Background(), tt.search) + keys, total, err := svc.ListAPIKeys(context.Background(), tt.search, 0, 0) if tt.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tt.errContains) @@ -332,16 +332,16 @@ func TestNewAccountAPIKeysCommand(t *testing.T) { } type mockAPIKeyServiceForCLI struct { - listFunc func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) + listFunc func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) createFunc func(ctx context.Context, name string) (*portalsdk.APIKey, error) deleteFunc func(ctx context.Context, idOrName string, force bool) error currentUUIDFunc func() string requireAuthErr error } -func (m *mockAPIKeyServiceForCLI) ListAPIKeys(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) { +func (m *mockAPIKeyServiceForCLI) ListAPIKeys(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) { if m.listFunc != nil { - return m.listFunc(ctx, search) + return m.listFunc(ctx, search, start, limit) } return nil, 0, nil } @@ -380,7 +380,7 @@ func setupAPIKeyHandlerTest(t *testing.T) (*mockAPIKeyServiceForCLI, config.Mana func TestAccountAPIKeysList_Success(t *testing.T) { mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) - mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) { + mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) { return []*portalsdk.APIKey{ newTestAPIKey("my-key", "00000000-0000-0000-0000-000000000001"), }, 1, nil @@ -401,7 +401,7 @@ func TestAccountAPIKeysList_Success(t *testing.T) { func TestAccountAPIKeysList_Empty(t *testing.T) { mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) - mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) { + mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) { return []*portalsdk.APIKey{}, 0, nil } @@ -420,7 +420,7 @@ func TestAccountAPIKeysList_Empty(t *testing.T) { func TestAccountAPIKeysList_WithSearch(t *testing.T) { mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) - mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) { + mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) { require.Equal(t, "my-key", search) return []*portalsdk.APIKey{}, 0, nil } @@ -440,7 +440,7 @@ func TestAccountAPIKeysList_WithSearch(t *testing.T) { func TestAccountAPIKeysList_ServiceError(t *testing.T) { mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) - mockSvc.listFunc = func(ctx context.Context, search string) ([]*portalsdk.APIKey, int, error) { + mockSvc.listFunc = func(ctx context.Context, search string, _ int, _ int) ([]*portalsdk.APIKey, int, error) { return nil, 0, fmt.Errorf("server error") } diff --git a/internal/cli/admin_platform_domains_test.go b/internal/cli/admin_platform_domains_test.go index fe2f7246..f6152a11 100644 --- a/internal/cli/admin_platform_domains_test.go +++ b/internal/cli/admin_platform_domains_test.go @@ -138,13 +138,13 @@ func TestAdminActionAdapterForwardsPositionalAndFlags(t *testing.T) { // driven by function fields, used to exercise resolvePlatformDomainID without a // real service or network. type fakePlatformDomainService struct { - listFn func(ctx context.Context) ([]*admin.PlatformDomain, int, error) + listFn func(ctx context.Context, params *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error) } func (f *fakePlatformDomainService) RequireAuthenticated() error { return nil } -func (f *fakePlatformDomainService) ListPlatformDomains(ctx context.Context) ([]*admin.PlatformDomain, int, error) { +func (f *fakePlatformDomainService) ListPlatformDomains(ctx context.Context, params *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error) { if f.listFn != nil { - return f.listFn(ctx) + return f.listFn(ctx, params) } return nil, 0, nil } @@ -166,7 +166,7 @@ func (f *fakePlatformDomainService) BindWebsiteToPlatformDomain(ctx context.Cont // ListPlatformDomains. func TestResolvePlatformDomainID(t *testing.T) { svc := &fakePlatformDomainService{ - listFn: func(ctx context.Context) ([]*admin.PlatformDomain, int, error) { + listFn: func(ctx context.Context, _ *admin.GetApiIpfsPlatformDomainsParams) ([]*admin.PlatformDomain, int, error) { d1 := &admin.PlatformDomain{} d1.Id, d1.Domain = 7, "pinned.site" d2 := &admin.PlatformDomain{} diff --git a/internal/cli/catalog_admin_wiring.go b/internal/cli/catalog_admin_wiring.go index 39c2e869..9a2c1685 100644 --- a/internal/cli/catalog_admin_wiring.go +++ b/internal/cli/catalog_admin_wiring.go @@ -278,7 +278,7 @@ func resolvePlatformDomainID(ctx context.Context, deps catalogops.AdminDeps, idO if err := svc.RequireAuthenticated(); err != nil { return "", err } - domains, _, err := svc.ListPlatformDomains(ctx) + domains, err := allPlatformDomains(ctx, svc) if err != nil { return "", fmt.Errorf("failed to look up platform domain by name: %w", err) } @@ -309,7 +309,7 @@ func resolveSocialProviderID(ctx context.Context, deps catalogops.AdminDeps, idO if err := svc.RequireAuthenticated(); err != nil { return "", err } - providers, _, err := svc.ListSocialProviders(ctx) + providers, err := allSocialProviders(ctx, svc) if err != nil { return "", fmt.Errorf("failed to look up social provider by key: %w", err) } @@ -321,6 +321,52 @@ func resolveSocialProviderID(ctx context.Context, deps catalogops.AdminDeps, idO return "", fmt.Errorf("social provider %q not found; run 'pinner admin social-providers list' to see configured providers", idOrKey) } +// allPlatformDomains pages through every registered platform domain. The admin +// API only returns a default 10-item window per request, so a name/id that +// lives past the first page must be found by scanning repeated _start/_end +// windows until the backend-reported total is reached. +func allPlatformDomains(ctx context.Context, svc coreadmin.PlatformDomainAdminService) ([]*admin.PlatformDomain, error) { + const pageSize = 100 + var all []*admin.PlatformDomain + for start := 0; ; start += pageSize { + pageStart, pageEnd := start, start+pageSize + page, total, err := svc.ListPlatformDomains(ctx, &admin.GetApiIpfsPlatformDomainsParams{ + UnderscoreStart: &pageStart, + UnderscoreEnd: &pageEnd, + }) + if err != nil { + return nil, err + } + all = append(all, page...) + if len(page) == 0 || len(page) < pageSize || (total > 0 && len(all) >= total) { + break + } + } + return all, nil +} + +// allSocialProviders pages through every configured social login provider, +// mirroring allPlatformDomains for the provider key -> ID resolution path. +func allSocialProviders(ctx context.Context, svc coreadmin.SocialProviderAdminService) ([]*admin.SocialProvider, error) { + const pageSize = 100 + var all []*admin.SocialProvider + for start := 0; ; start += pageSize { + pageStart, pageEnd := start, start+pageSize + page, total, err := svc.ListSocialProviders(ctx, &admin.GetApiSocialProvidersParams{ + UnderscoreStart: &pageStart, + UnderscoreEnd: &pageEnd, + }) + if err != nil { + return nil, err + } + all = append(all, page...) + if len(page) == 0 || len(page) < pageSize || (total > 0 && len(all) >= total) { + break + } + } + return all, nil +} + // renderAdminResult renders an admin handler's typed result through the CLI // Output formatter. func renderAdminResult(_ context.Context, c *cli.Command, op opmesh.Operation, result any) error { diff --git a/internal/cli/dns.go b/internal/cli/dns.go index dadc6833..19fda941 100644 --- a/internal/cli/dns.go +++ b/internal/cli/dns.go @@ -6,6 +6,7 @@ import ( "strconv" ipfs "go.lumeweb.com/ipfs-sdk" + opmesh "go.lumeweb.com/opmesh" "go.lumeweb.com/pinner/dnsutil" ) @@ -47,15 +48,28 @@ func resolveZoneID(ctx context.Context, dnsService DNSService, arg string) (stri return arg, nil } - zones, err := dnsService.ListZones(ctx) - if err != nil { - return "", fmt.Errorf("failed to look up zone by domain: %w", err) - } + // The backend applies a default 10-row window to ListZonesPage, so resolve a + // domain by paging through every window rather than relying on the first page + // alone. ListZones is a thin wrapper over ListZonesPage with an empty window + // (only the first page), which would miss any zone past the first 10. + const pageSize = 100 + start := 0 + for { + zones, total, err := dnsService.ListZonesPage(ctx, opmesh.ListOptions[struct{}]{Start: start, Limit: pageSize}) + if err != nil { + return "", fmt.Errorf("failed to look up zone by domain: %w", err) + } + + for _, z := range zones { + if z.Domain == arg { + return fmt.Sprintf("%d", z.Id), nil + } + } - for _, z := range zones { - if z.Domain == arg { - return fmt.Sprintf("%d", z.Id), nil + if len(zones) == 0 || len(zones) < pageSize || start+len(zones) >= total { + break } + start += len(zones) } return "", fmt.Errorf("zone not found for domain %q", arg) diff --git a/internal/cli/dns_test.go b/internal/cli/dns_test.go index 027355f3..df1650c1 100644 --- a/internal/cli/dns_test.go +++ b/internal/cli/dns_test.go @@ -9,11 +9,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ipfs "go.lumeweb.com/ipfs-sdk" + opmesh "go.lumeweb.com/opmesh" ) type mockDNSServiceForCLI struct { requireAuthenticatedErr error listZonesFunc func(ctx context.Context) ([]ipfs.ZoneListResponse, error) + listZonesPageFunc func(ctx context.Context, opts opmesh.ListOptions[struct{}]) ([]ipfs.ZoneListResponse, int, error) + listRecordsPageFunc func(ctx context.Context, id string, opts opmesh.ListOptions[struct{}]) ([]ipfs.RecordResponse, int, error) createZoneFunc func(ctx context.Context, domain string, nameservers []string) (*ipfs.ZoneResponse, error) getZoneFunc func(ctx context.Context, id string) (*ipfs.ZoneResponse, error) deleteZoneFunc func(ctx context.Context, id string) error @@ -38,6 +41,17 @@ func (m *mockDNSServiceForCLI) ListZones(ctx context.Context) ([]ipfs.ZoneListRe return nil, nil } +func (m *mockDNSServiceForCLI) ListZonesPage(ctx context.Context, opts opmesh.ListOptions[struct{}]) ([]ipfs.ZoneListResponse, int, error) { + if m.listZonesPageFunc != nil { + return m.listZonesPageFunc(ctx, opts) + } + if m.listZonesFunc != nil { + zones, err := m.listZonesFunc(ctx) + return zones, len(zones), err + } + return nil, 0, nil +} + func (m *mockDNSServiceForCLI) CreateZone(ctx context.Context, domain string, nameservers []string) (*ipfs.ZoneResponse, error) { if m.createZoneFunc != nil { return m.createZoneFunc(ctx, domain, nameservers) @@ -80,6 +94,17 @@ func (m *mockDNSServiceForCLI) ListRecords(ctx context.Context, id string) ([]ip return nil, nil } +func (m *mockDNSServiceForCLI) ListRecordsPage(ctx context.Context, id string, opts opmesh.ListOptions[struct{}]) ([]ipfs.RecordResponse, int, error) { + if m.listRecordsPageFunc != nil { + return m.listRecordsPageFunc(ctx, id, opts) + } + if m.listRecordsFunc != nil { + records, err := m.listRecordsFunc(ctx, id) + return records, len(records), err + } + return nil, 0, nil +} + func (m *mockDNSServiceForCLI) GetRecord(ctx context.Context, id string, name string, recordType string) (*ipfs.RecordResponse, error) { if m.getRecordFunc != nil { return m.getRecordFunc(ctx, id, name, recordType) diff --git a/internal/cli/ipns_service_test.go b/internal/cli/ipns_service_test.go index 79071c58..69c3d173 100644 --- a/internal/cli/ipns_service_test.go +++ b/internal/cli/ipns_service_test.go @@ -87,6 +87,7 @@ func TestIPNSService_ListKeys(t *testing.T) { type mockIPNSServiceForCLI struct { requireAuthenticatedErr error listKeysFunc func(ctx context.Context, opts ...ipfs.ListKeyOption) ([]ipfs.IPNSKeyResponse, error) + listKeysPageFunc func(ctx context.Context, opts ...ipfs.IPNSKeyPagingOption) (*ipfs.IPNSKeyPage, error) createKeyFunc func(ctx context.Context, name string, key *string) (*ipfs.IPNSKeyResponse, error) getKeyFunc func(ctx context.Context, id string) (*ipfs.IPNSKeyResponse, error) deleteKeyFunc func(ctx context.Context, id string) error @@ -112,6 +113,13 @@ func (m *mockIPNSServiceForCLI) ListKeys(ctx context.Context, opts ...ipfs.ListK }, nil } +func (m *mockIPNSServiceForCLI) ListKeysPage(ctx context.Context, opts ...ipfs.IPNSKeyPagingOption) (*ipfs.IPNSKeyPage, error) { + if m.listKeysPageFunc != nil { + return m.listKeysPageFunc(ctx, opts...) + } + return nil, nil +} + func (m *mockIPNSServiceForCLI) CreateKey(ctx context.Context, name string, key *string) (*ipfs.IPNSKeyResponse, error) { if m.createKeyFunc != nil { return m.createKeyFunc(ctx, name, key) diff --git a/internal/cli/mock_APIKeyService.go b/internal/cli/mock_APIKeyService.go index a99b022c..b964b1be 100644 --- a/internal/cli/mock_APIKeyService.go +++ b/internal/cli/mock_APIKeyService.go @@ -214,8 +214,8 @@ func (_c *MockAPIKeyService_GetCurrentAPIKeyUUID_Call) RunAndReturn(run func() s } // ListAPIKeys provides a mock function for the type MockAPIKeyService -func (_mock *MockAPIKeyService) ListAPIKeys(ctx context.Context, search string) ([]*account.APIKey, int, error) { - ret := _mock.Called(ctx, search) +func (_mock *MockAPIKeyService) ListAPIKeys(ctx context.Context, search string, start int, limit int) ([]*account.APIKey, int, error) { + ret := _mock.Called(ctx, search, start, limit) if len(ret) == 0 { panic("no return value specified for ListAPIKeys") @@ -224,23 +224,23 @@ func (_mock *MockAPIKeyService) ListAPIKeys(ctx context.Context, search string) var r0 []*account.APIKey var r1 int var r2 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*account.APIKey, int, error)); ok { - return returnFunc(ctx, search) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*account.APIKey, int, error)); ok { + return returnFunc(ctx, search, start, limit) } - if returnFunc, ok := ret.Get(0).(func(context.Context, string) []*account.APIKey); ok { - r0 = returnFunc(ctx, search) + if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*account.APIKey); ok { + r0 = returnFunc(ctx, search, start, limit) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*account.APIKey) } } - if returnFunc, ok := ret.Get(1).(func(context.Context, string) int); ok { - r1 = returnFunc(ctx, search) + if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) int); ok { + r1 = returnFunc(ctx, search, start, limit) } else { r1 = ret.Get(1).(int) } - if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { - r2 = returnFunc(ctx, search) + if returnFunc, ok := ret.Get(2).(func(context.Context, string, int, int) error); ok { + r2 = returnFunc(ctx, search, start, limit) } else { r2 = ret.Error(2) } @@ -255,11 +255,13 @@ type MockAPIKeyService_ListAPIKeys_Call struct { // ListAPIKeys is a helper method to define mock.On call // - ctx context.Context // - search string -func (_e *MockAPIKeyService_Expecter) ListAPIKeys(ctx interface{}, search interface{}) *MockAPIKeyService_ListAPIKeys_Call { - return &MockAPIKeyService_ListAPIKeys_Call{Call: _e.mock.On("ListAPIKeys", ctx, search)} +// - start int +// - limit int +func (_e *MockAPIKeyService_Expecter) ListAPIKeys(ctx interface{}, search interface{}, start interface{}, limit interface{}) *MockAPIKeyService_ListAPIKeys_Call { + return &MockAPIKeyService_ListAPIKeys_Call{Call: _e.mock.On("ListAPIKeys", ctx, search, start, limit)} } -func (_c *MockAPIKeyService_ListAPIKeys_Call) Run(run func(ctx context.Context, search string)) *MockAPIKeyService_ListAPIKeys_Call { +func (_c *MockAPIKeyService_ListAPIKeys_Call) Run(run func(ctx context.Context, search string, start int, limit int)) *MockAPIKeyService_ListAPIKeys_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -269,9 +271,19 @@ func (_c *MockAPIKeyService_ListAPIKeys_Call) Run(run func(ctx context.Context, if args[1] != nil { arg1 = args[1].(string) } + var arg2 int + if args[2] != nil { + arg2 = args[2].(int) + } + var arg3 int + if args[3] != nil { + arg3 = args[3].(int) + } run( arg0, arg1, + arg2, + arg3, ) }) return _c @@ -282,7 +294,7 @@ func (_c *MockAPIKeyService_ListAPIKeys_Call) Return(aPIKeys []*account.APIKey, return _c } -func (_c *MockAPIKeyService_ListAPIKeys_Call) RunAndReturn(run func(ctx context.Context, search string) ([]*account.APIKey, int, error)) *MockAPIKeyService_ListAPIKeys_Call { +func (_c *MockAPIKeyService_ListAPIKeys_Call) RunAndReturn(run func(ctx context.Context, search string, start int, limit int) ([]*account.APIKey, int, error)) *MockAPIKeyService_ListAPIKeys_Call { _c.Call.Return(run) return _c } From 00607992a6c0cc65452b67f6a86a0b3ce680dbb0 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Tue, 15 Sep 2026 20:03:21 +0000 Subject: [PATCH 2/3] fix(cli): page through all API keys in list and name resolution The account api-keys list and delete-by-name paths called ListAPIKeys with a zero window, so the backend's default 10-row page truncated results: the list rendered only the first page while reporting the full count, and deleting a key by name past the first page failed. - Add an allAPIKeys full-scan helper that walks explicit start/limit windows until every key is collected, mirroring the allPlatformDomains/allSocialProviders pattern - Use it in the list handler so all keys are shown - Use it in delete-by-name resolution so keys beyond the first page resolve to their UUID before deletion - Add regression tests covering a >10-key list and name resolution past the first page --- internal/cli/account_api_keys.go | 32 ++++++- internal/cli/account_api_keys_test.go | 120 ++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/internal/cli/account_api_keys.go b/internal/cli/account_api_keys.go index ff14c8f8..49a696ff 100644 --- a/internal/cli/account_api_keys.go +++ b/internal/cli/account_api_keys.go @@ -20,10 +20,11 @@ func accountAPIKeysList(ctx context.Context, cmd flagGetter, output Output, cfgM svc := svcFactory(authService, authToken) search := cmd.String(FlagSearch) - keys, total, err := svc.ListAPIKeys(ctx, search, 0, 0) + keys, err := allAPIKeys(ctx, svc, search) if err != nil { return fmt.Errorf("failed to list API keys: %w", err) } + total := len(keys) if len(keys) == 0 { if output.IsJSON() { @@ -58,6 +59,27 @@ func accountAPIKeysList(ctx context.Context, cmd flagGetter, output Output, cfgM return nil } +// allAPIKeys pages through every API key for the authenticated account. The +// backend applies a default 10-row window to ListAPIKeys unless an explicit +// _start/_end window is supplied, so a key that lives past the first page would +// otherwise be missed. This mirrors the allPlatformDomains/allSocialProviders +// full-scan helpers used for the admin name/id resolution paths. +func allAPIKeys(ctx context.Context, svc APIKeyService, search string) ([]*portalsdk.APIKey, error) { + const pageSize = 100 + var all []*portalsdk.APIKey + for start := 0; ; start += pageSize { + keys, total, err := svc.ListAPIKeys(ctx, search, start, pageSize) + if err != nil { + return nil, err + } + all = append(all, keys...) + if len(keys) == 0 || len(keys) < pageSize || (total > 0 && len(all) >= total) { + break + } + } + return all, nil +} + func accountAPIKeysCreate(ctx context.Context, cmd argsFlagGetter, output Output, cfgMgr config.Manager, authToken string, authServiceFactory AuthServiceFactory, svcFactory APIKeyServiceFactory) error { apiEndpoint := cfgMgr.Config().GetAPIEndpoint() authService := authServiceFactory(cfgMgr, apiEndpoint) @@ -107,8 +129,10 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu currentUUID := svc.GetCurrentAPIKeyUUID() resolvedID := idOrName - if currentUUID != "" && !isUUIDString(idOrName) { - keys, _, listErr := svc.ListAPIKeys(ctx, idOrName, 0, 0) + if !isUUIDString(idOrName) { + // Resolve a name to its UUID via a full scan so keys past the backend's + // first page are found, then delete by the resolved UUID directly. + keys, listErr := allAPIKeys(ctx, svc, "") if listErr == nil { for _, key := range keys { if key.Name == idOrName { @@ -120,7 +144,7 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu } isCurrentKey := currentUUID != "" && currentUUID == resolvedID - if err := svc.DeleteAPIKey(ctx, idOrName, force); err != nil { + if err := svc.DeleteAPIKey(ctx, resolvedID, force); err != nil { return err } diff --git a/internal/cli/account_api_keys_test.go b/internal/cli/account_api_keys_test.go index 4559fb37..033e47f6 100644 --- a/internal/cli/account_api_keys_test.go +++ b/internal/cli/account_api_keys_test.go @@ -1,10 +1,12 @@ package cli import ( + "bytes" "context" "encoding/base64" "encoding/json" "fmt" + "strings" "testing" mock "github.com/stretchr/testify/mock" @@ -593,6 +595,124 @@ func TestAccountAPIKeysDelete_WithForce(t *testing.T) { require.NoError(t, err) } +func newTestAPIKeyBatch(n int) []*portalsdk.APIKey { + keys := make([]*portalsdk.APIKey, 0, n) + for i := 0; i < n; i++ { + keys = append(keys, newTestAPIKey( + fmt.Sprintf("key-%03d", i+1), + fmt.Sprintf("00000000-0000-0000-0000-%012d", i+1), + )) + } + return keys +} + +// pagedAPIKeyList emulates the portal admin API-key list endpoint. With no +// explicit _start/_end window (limit == 0) the backend applies a default 10-row +// window; with an explicit window it returns the requested slice. search +// narrows by name substring, matching the backend's q filter. +func pagedAPIKeyList(keys []*portalsdk.APIKey) func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) { + return func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) { + matched := keys + if search != "" { + matched = nil + for _, k := range keys { + if strings.Contains(k.Name, search) { + matched = append(matched, k) + } + } + } + end := len(matched) + if limit > 0 { + end = min(start+limit, len(matched)) + } else { + end = min(10, len(matched)) + } + if start >= len(matched) { + return []*portalsdk.APIKey{}, len(matched), nil + } + if end < start { + end = start + } + return matched[start:end], len(matched), nil + } +} + +func TestAllAPIKeys_FullScanReturnsEveryPage(t *testing.T) { + keys := newTestAPIKeyBatch(125) + mockSvc := &mockAPIKeyServiceForCLI{} + mockSvc.listFunc = pagedAPIKeyList(keys) + + // The naive single call with no explicit window only surfaces the first 10 + // rows while reporting the full total — the truncation this guards against. + naiveKeys, naiveTotal, err := mockSvc.ListAPIKeys(context.Background(), "", 0, 0) + require.NoError(t, err) + require.Equal(t, len(keys), naiveTotal) + require.Len(t, naiveKeys, 10) + + all, err := allAPIKeys(context.Background(), mockSvc, "") + require.NoError(t, err) + require.Len(t, all, len(keys)) +} + +func TestAccountAPIKeysList_PagesAllKeys(t *testing.T) { + keys := newTestAPIKeyBatch(125) + mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) + mockSvc.listFunc = pagedAPIKeyList(keys) + + var buf bytes.Buffer + output := NewOutputFormatter(true, false, false, false) + output.SetWriter(&buf) + + cmd := newMockCommand() + err := accountAPIKeysList(context.Background(), cmd, output, cfgMgr, "test-token", + func(cm config.Manager, apiEndpoint string) AuthService { + return NewMockAuthService(t) + }, + func(authService AuthService, authToken string) APIKeyService { + return mockSvc + }, + ) + require.NoError(t, err) + + var result struct { + Count int `json:"count"` + Keys []*portalsdk.APIKey `json:"keys"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Equal(t, len(keys), result.Count) + require.Len(t, result.Keys, len(keys)) +} + +func TestAccountAPIKeysDelete_ResolvesNameBeyondFirstPage(t *testing.T) { + keys := newTestAPIKeyBatch(125) + mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) + mockSvc.listFunc = pagedAPIKeyList(keys) + mockSvc.currentUUIDFunc = func() string { + return "00000000-0000-0000-0000-999999999999" + } + + var deletedID string + mockSvc.deleteFunc = func(ctx context.Context, idOrName string, force bool) error { + deletedID = idOrName + return nil + } + + // key-110 sits well past the backend's first 10-row page; the full scan must + // resolve it to its UUID so the delete targets it directly. + output := newTestOutput() + cmd := newMockCommand().withArgs("key-110") + err := accountAPIKeysDelete(context.Background(), cmd, output, cfgMgr, "test-token", + func(cm config.Manager, apiEndpoint string) AuthService { + return NewMockAuthService(t) + }, + func(authService AuthService, authToken string) APIKeyService { + return mockSvc + }, + ) + require.NoError(t, err) + require.Equal(t, "00000000-0000-0000-0000-000000000110", deletedID) +} + // makeAPIKeyJWT creates a minimal JWT string with the given subject and audience. func makeAPIKeyJWT(sub, aud string) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) From 3bdf2cdef0cb59262fd0909229f49cea60de3940 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Tue, 15 Sep 2026 20:14:27 +0000 Subject: [PATCH 3/3] fix(cli): filter delete-by-name by the provided name instead of full-scanning accountAPIKeysDelete resolved a name to its UUID by full-scanning every API key (allAPIKeys with an empty search), so the backend returned the account's whole key set. Pass the provided name as the backend search filter so the backend narrows the pages, while keeping allAPIKeys' paginated scan (explicit start/limit windows plus the short-page/total termination guards) so a match past the backend's default first page still resolves before deletion. Update the regression test to prove filtered paging reaches a key beyond the first page: keys share a common prefix so the name search returns multiple full pages of filtered rows, the exact-name target sits past the first page, and every paged request carries the name as the search filter. --- internal/cli/account_api_keys.go | 10 ++++--- internal/cli/account_api_keys_test.go | 38 ++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/internal/cli/account_api_keys.go b/internal/cli/account_api_keys.go index 49a696ff..b1bd221b 100644 --- a/internal/cli/account_api_keys.go +++ b/internal/cli/account_api_keys.go @@ -130,9 +130,13 @@ func accountAPIKeysDelete(ctx context.Context, cmd argsFlagGetterWithBool, outpu currentUUID := svc.GetCurrentAPIKeyUUID() resolvedID := idOrName if !isUUIDString(idOrName) { - // Resolve a name to its UUID via a full scan so keys past the backend's - // first page are found, then delete by the resolved UUID directly. - keys, listErr := allAPIKeys(ctx, svc, "") + // Resolve a name to its UUID using the name as the backend search + // filter, while still paging through the (filtered) results so keys + // past the backend's default first page are found. Passing the name as + // the filter keeps the backend from returning every key on the account; + // the paginated scan in allAPIKeys guarantees a match deeper than the + // first page still resolves before we delete by the resolved UUID. + keys, listErr := allAPIKeys(ctx, svc, idOrName) if listErr == nil { for _, key := range keys { if key.Name == idOrName { diff --git a/internal/cli/account_api_keys_test.go b/internal/cli/account_api_keys_test.go index 033e47f6..2b658cbd 100644 --- a/internal/cli/account_api_keys_test.go +++ b/internal/cli/account_api_keys_test.go @@ -684,9 +684,30 @@ func TestAccountAPIKeysList_PagesAllKeys(t *testing.T) { } func TestAccountAPIKeysDelete_ResolvesNameBeyondFirstPage(t *testing.T) { - keys := newTestAPIKeyBatch(125) + // Names share the "key-110" prefix so the backend search (name substring) + // returns far more than one pageSize of filtered rows. The exact-name + // target is placed past the first page of filtered results to prove that + // filtered paging still reaches it — and that the backend is asked to + // narrow by name rather than handed a full-scan search. + const total = 205 + keys := make([]*portalsdk.APIKey, 0, total) + for i := 0; i < total; i++ { + if i == 150 { + keys = append(keys, newTestAPIKey("key-110", "00000000-0000-0000-0000-000000000110")) + continue + } + keys = append(keys, newTestAPIKey( + fmt.Sprintf("key-110-%03d", i), + fmt.Sprintf("00000000-0000-0000-0000-%012d", i+1), + )) + } + mockSvc, cfgMgr := setupAPIKeyHandlerTest(t) - mockSvc.listFunc = pagedAPIKeyList(keys) + var searches []string + mockSvc.listFunc = func(ctx context.Context, search string, start, limit int) ([]*portalsdk.APIKey, int, error) { + searches = append(searches, search) + return pagedAPIKeyList(keys)(ctx, search, start, limit) + } mockSvc.currentUUIDFunc = func() string { return "00000000-0000-0000-0000-999999999999" } @@ -697,8 +718,8 @@ func TestAccountAPIKeysDelete_ResolvesNameBeyondFirstPage(t *testing.T) { return nil } - // key-110 sits well past the backend's first 10-row page; the full scan must - // resolve it to its UUID so the delete targets it directly. + // key-110 sits well past the backend's first page; the name-filtered paged + // scan must resolve it to its UUID so the delete targets it directly. output := newTestOutput() cmd := newMockCommand().withArgs("key-110") err := accountAPIKeysDelete(context.Background(), cmd, output, cfgMgr, "test-token", @@ -711,6 +732,15 @@ func TestAccountAPIKeysDelete_ResolvesNameBeyondFirstPage(t *testing.T) { ) require.NoError(t, err) require.Equal(t, "00000000-0000-0000-0000-000000000110", deletedID) + + // Prove the optimization: every paged request carried the name as the + // backend search filter (not a full-scan ""), yet paging still spanned + // multiple filtered pages to reach the key past the first page. + require.NotEmpty(t, searches) + require.Greater(t, len(searches), 1) + for _, s := range searches { + require.Equal(t, "key-110", s) + } } // makeAPIKeyJWT creates a minimal JWT string with the given subject and audience.