diff --git a/go.mod b/go.mod index 12369e53..a84716a3 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( go.lumeweb.com/ipfs-sdk v0.1.89 go.lumeweb.com/ipfs-sdk/dnsname v0.1.64 go.lumeweb.com/oauth v0.1.6 - go.lumeweb.com/portal-sdk v0.1.69 + go.lumeweb.com/portal-sdk v0.1.71 go.lumeweb.com/queryutil v0.3.19 go.sia.tech/core v0.21.7 go.sia.tech/indexd v0.4.4 diff --git a/go.sum b/go.sum index d9f02df0..b3127240 100644 --- a/go.sum +++ b/go.sum @@ -747,8 +747,8 @@ go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R 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.69 h1:qyXietI23pCe+sj1zC7Q8lQQ+CmpsMvVe0p2Rb/T4Yg= -go.lumeweb.com/portal-sdk v0.1.69/go.mod h1:xwNOHOTaqcjvg7P3N8QhNDz9rITQyDQXPO4W8PWythE= +go.lumeweb.com/portal-sdk v0.1.71 h1:wPnR2zufo96Ao/vML5f5P2uyK0mDTPW2JOsePuy/WdI= +go.lumeweb.com/portal-sdk v0.1.71/go.mod h1:KKxa2hV+44EI+0riJIjREvz7/sqgWGRTxtLUGgnADZg= go.lumeweb.com/queryutil v0.3.19 h1:GV+zKsyGJ+YYPZi0mqOocDAROCQruGdizrE8Aw8cNFk= go.lumeweb.com/queryutil v0.3.19/go.mod h1:YPrXdEelsjJNBPXrG4IiYp4aq5YUk/l4uXw8z2cSAuw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/catalogops/admin_deps.go b/internal/catalogops/admin_deps.go index 97b962da..74f9f4fa 100644 --- a/internal/catalogops/admin_deps.go +++ b/internal/catalogops/admin_deps.go @@ -39,6 +39,8 @@ type AdminDeps struct { WebsiteAdminService func(cfgMgr config.Manager) (admin.WebsiteAdminService, error) // PlatformDomainAdminService resolves the core admin.PlatformDomainAdminService. PlatformDomainAdminService func(cfgMgr config.Manager) (admin.PlatformDomainAdminService, error) + // SocialProviderAdminService resolves the core admin.SocialProviderAdminService. + SocialProviderAdminService func(cfgMgr config.Manager) (admin.SocialProviderAdminService, error) } // config returns the live config manager for this invocation, or nil. @@ -108,6 +110,15 @@ func (d AdminDeps) billing() (admin.BillingAdminService, error) { return resolveService(cfgMgr, d.BillingAdminService, "billing") } +// socialProviders resolves the SocialProviderAdminService for this invocation. +func (d AdminDeps) socialProviders() (admin.SocialProviderAdminService, error) { + cfgMgr, err := d.requireConfig() + if err != nil { + return nil, err + } + return resolveService(cfgMgr, d.SocialProviderAdminService, "social-provider") +} + // AdminOperations returns the catalog operations for the admin domain. Each // admin section registers its operations here. func AdminOperations(d AdminDeps) []catalog.Operation { @@ -121,6 +132,14 @@ func AdminOperations(d AdminDeps) []catalog.Operation { // admin websites adminWebsitesBlock(d), adminWebsitesUnblock(d), + // admin social-providers + adminSocialProvidersList(d), + adminSocialProvidersGet(d), + adminSocialProvidersCreate(d), + adminSocialProvidersUpdate(d), + adminSocialProvidersDelete(d), + adminSocialProvidersEnable(d), + adminSocialProvidersDisable(d), // admin quota adminQuotaPlansList(d), adminQuotaPlansGet(d), diff --git a/internal/catalogops/admin_social_providers.go b/internal/catalogops/admin_social_providers.go new file mode 100644 index 00000000..b4cb5263 --- /dev/null +++ b/internal/catalogops/admin_social_providers.go @@ -0,0 +1,372 @@ +package catalogops + +import ( + "context" + "fmt" + + "go.lumeweb.com/pinner-cli/internal/catalog" + "go.lumeweb.com/portal-sdk/admin" +) + +// socialProvidersListResult builds the shared ListResult view for the social +// providers list operation. +func socialProvidersListResult(providers []*admin.SocialProvider) ListResult { + headers := []string{"ID", "PROVIDER", "DISPLAY NAME", "ENABLED", "ORDER"} + return NewListResult(providers, ListResultMeta{Noun: "social provider(s)", Headers: headers, Rows: socialProviderRows(providers)}) +} + +// socialProvidersListResultTotal is socialProvidersListResult with the backend +// total attached (for list pagination display). +func socialProvidersListResultTotal(providers []*admin.SocialProvider, total int) ListResult { + return NewListResult(providers, ListResultMeta{Noun: "social provider(s)", Headers: []string{"ID", "PROVIDER", "DISPLAY NAME", "ENABLED", "ORDER"}, Rows: socialProviderRows(providers), Total: total}) +} + +func socialProviderRows(providers []*admin.SocialProvider) [][]string { + rows := make([][]string, 0, len(providers)) + for _, p := range providers { + rows = append(rows, []string{ + fmt.Sprintf("%d", p.Id), p.ProviderId, p.DisplayName, + adminYesNo(p.Enabled), fmt.Sprintf("%d", p.OrderIndex), + }) + } + return rows +} + +// SocialProvidersDeleteResult reports a deleted social provider. +type SocialProvidersDeleteResult struct { + Deleted bool `json:"deleted"` + ID string `json:"id"` +} + +// socialProviderRequestFromInput builds a SocialProviderRequest from the op +// input. Required identity fields are validated by the caller so the handler +// can relax the CLI Required markers for positionals. +func socialProviderRequestFromInput(input map[string]any) *admin.SocialProviderRequest { + return &admin.SocialProviderRequest{ + ProviderId: catalog.StrArg(input, "provider-id", ""), + ClientId: catalog.StrArg(input, "client-id", ""), + ClientSecret: catalog.StrArg(input, "client-secret", ""), + DisplayName: catalog.StrArg(input, "display-name", ""), + AuthUrl: catalog.StrArg(input, "auth-url", ""), + TokenUrl: catalog.StrArg(input, "token-url", ""), + UserUrl: catalog.StrArg(input, "user-url", ""), + Scopes: catalog.StrSliceArg(input, "scopes"), + UserIdKey: catalog.StrArg(input, "user-id-key", ""), + UserEmailKey: catalog.StrArg(input, "user-email-key", ""), + UserNameKey: catalog.StrArg(input, "user-name-key", ""), + OrderIndex: catalog.IntArg(input, "order-index", 0), + Enabled: catalog.BoolArg(input, "enabled", false), + } +} + +// adminSocialProvidersList is the `admin social-providers list` operation. +func adminSocialProvidersList(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_list", + Title: "List social providers", + Summary: "List all social login providers", + Description: "List all configured social login providers. Client secrets are never returned. Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyRead, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Args: catalog.ListArgs(), + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + providers, total, err := svc.ListSocialProviders(ctx) + if err != nil { + return nil, err + } + page := catalog.ParseList(input) + paged := slicePage(providers, page.Start, page.Limit) + return socialProvidersListResultTotal(paged, total), nil + }), + }) +} + +// adminSocialProvidersGet is the `admin social-providers get` operation. +func adminSocialProvidersGet(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_get", + Title: "Get a social provider", + Summary: "Get a social login provider by ID", + Description: "Get a single social login provider by numeric ID. Client secrets are never returned. Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyRead, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Positional: "", + Args: []catalog.OperationArg{ + {Name: "id", Type: catalog.ArgTypeString, Required: true, Help: "Social provider ID", PositionalOnly: true}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + id := catalog.StrArg(input, "id", "") + if id == "" { + return nil, fmt.Errorf("admin_social_providers_get: provider ID is required") + } + return svc.GetSocialProvider(ctx, id) + }), + }) +} + +// adminSocialProvidersCreate is the `admin social-providers create` operation. +func adminSocialProvidersCreate(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_create", + Title: "Create a social provider", + Summary: "Create a social login provider configuration", + Description: "Create a new social login provider configuration (OAuth2 endpoints, client credentials, attribute keys and display metadata). Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyMutate, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Args: []catalog.OperationArg{ + {Name: "provider-id", Type: catalog.ArgTypeString, Required: true, Help: "Provider type identifier (e.g. github, google)"}, + {Name: "client-id", Type: catalog.ArgTypeString, Required: true, Help: "OAuth2 client ID"}, + {Name: "client-secret", Type: catalog.ArgTypeString, Required: true, Help: "OAuth2 client secret"}, + {Name: "display-name", Type: catalog.ArgTypeString, Required: true, Help: "Human-readable provider name"}, + {Name: "auth-url", Type: catalog.ArgTypeString, Required: true, Help: "OAuth2 authorization endpoint"}, + {Name: "token-url", Type: catalog.ArgTypeString, Required: true, Help: "OAuth2 token endpoint"}, + {Name: "user-url", Type: catalog.ArgTypeString, Required: true, Help: "User info endpoint"}, + {Name: "scopes", Type: catalog.ArgTypeStringSlice, Help: "OAuth2 scopes to request"}, + {Name: "user-id-key", Type: catalog.ArgTypeString, Required: true, Help: "User info JSON key holding the user ID"}, + {Name: "user-email-key", Type: catalog.ArgTypeString, Required: true, Help: "User info JSON key holding the email"}, + {Name: "user-name-key", Type: catalog.ArgTypeString, Required: true, Help: "User info JSON key holding the display name"}, + {Name: "order-index", Type: catalog.ArgTypeInt, Help: "Display order (lower first)"}, + {Name: "enabled", Type: catalog.ArgTypeBool, Help: "Enable the provider for login"}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + req := socialProviderRequestFromInput(input) + if req.ProviderId == "" { + return nil, fmt.Errorf("admin_social_providers_create: provider-id is required") + } + if req.ClientId == "" { + return nil, fmt.Errorf("admin_social_providers_create: client-id is required") + } + if req.ClientSecret == "" { + return nil, fmt.Errorf("admin_social_providers_create: client-secret is required") + } + if req.DisplayName == "" { + return nil, fmt.Errorf("admin_social_providers_create: display-name is required") + } + return svc.CreateSocialProvider(ctx, req) + }), + }) +} + +// adminSocialProvidersUpdate is the `admin social-providers update` operation. +func adminSocialProvidersUpdate(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_update", + Title: "Update a social provider", + Summary: "Update a social login provider configuration", + // Nullable arg types matter for updates: omitted must be distinguishable + // from the zero value, else an update without the enabled flag would + // arrive as enabled=false and the backend would disable the provider. + Description: "Update an existing social login provider configuration. Only the fields provided are changed; others keep their current values (an omitted client-secret keeps the stored secret). Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyMutate, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Positional: "", + Args: []catalog.OperationArg{ + {Name: "id", Type: catalog.ArgTypeString, Required: true, Help: "Social provider ID", PositionalOnly: true}, + {Name: "client-secret", Type: catalog.ArgTypeString, Help: "OAuth2 client secret (omit to keep the stored secret)"}, + {Name: "provider-key", Type: catalog.ArgTypeString, Help: "Provider type identifier (e.g. github, google)"}, + {Name: "client-id", Type: catalog.ArgTypeString, Help: "OAuth2 client ID"}, + {Name: "display-name", Type: catalog.ArgTypeString, Help: "Human-readable provider name"}, + {Name: "auth-url", Type: catalog.ArgTypeString, Help: "OAuth2 authorization endpoint"}, + {Name: "token-url", Type: catalog.ArgTypeString, Help: "OAuth2 token endpoint"}, + {Name: "user-url", Type: catalog.ArgTypeString, Help: "User info endpoint"}, + {Name: "scopes", Type: catalog.ArgTypeStringSlice, Help: "OAuth2 scopes to request (replaces the current set; omit to keep it)"}, + {Name: "user-id-key", Type: catalog.ArgTypeString, Help: "User info JSON key holding the user ID"}, + {Name: "user-email-key", Type: catalog.ArgTypeString, Help: "User info JSON key holding the email"}, + {Name: "user-name-key", Type: catalog.ArgTypeString, Help: "User info JSON key holding the display name"}, + {Name: "order-index", Type: catalog.ArgTypeNullableInt, Help: "Display order (lower first)"}, + {Name: "enabled", Type: catalog.ArgTypeNullableBool, Help: "Enable the provider for login"}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + id := catalog.StrArg(input, "id", "") + if id == "" { + return nil, fmt.Errorf("admin_social_providers_update: provider ID is required") + } + // Nil fields are sent omitted and the backend leaves them unchanged. + req := &admin.SocialProviderUpdateRequest{} + if v := catalog.StrArg(input, "provider-key", ""); v != "" { + req.ProviderId = &v + } + if v := catalog.StrArg(input, "client-id", ""); v != "" { + req.ClientId = &v + } + if v := catalog.StrArg(input, "client-secret", ""); v != "" { + req.ClientSecret = &v + } + if v := catalog.StrArg(input, "display-name", ""); v != "" { + req.DisplayName = &v + } + if v := catalog.StrArg(input, "auth-url", ""); v != "" { + req.AuthUrl = &v + } + if v := catalog.StrArg(input, "token-url", ""); v != "" { + req.TokenUrl = &v + } + if v := catalog.StrArg(input, "user-url", ""); v != "" { + req.UserUrl = &v + } + // An omitted slice arg normalizes to a non-nil empty []string, so + // presence is judged by length: forwarding that empty slice on the + // patch would erase all scopes a provider still needs. + if v := catalog.StrSliceArg(input, "scopes"); len(v) > 0 { + req.Scopes = &v + } + if v := catalog.StrArg(input, "user-id-key", ""); v != "" { + req.UserIdKey = &v + } + if v := catalog.StrArg(input, "user-email-key", ""); v != "" { + req.UserEmailKey = &v + } + if v := catalog.StrArg(input, "user-name-key", ""); v != "" { + req.UserNameKey = &v + } + if v := catalog.IntArgPtr(input, "order-index"); v != nil { + req.OrderIndex = v + } + if v := catalog.BoolArgPtr(input, "enabled"); v != nil { + req.Enabled = v + } + return svc.UpdateSocialProvider(ctx, id, req) + }), + }) +} + +// adminSocialProvidersDelete is the `admin social-providers delete` operation. +// DESTRUCTIVE: requires confirm=true. +func adminSocialProvidersDelete(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_delete", + Title: "Delete a social provider", + Summary: "Delete a social login provider by ID", + Description: "Delete a social login provider configuration by ID. DESTRUCTIVE: users will no longer be able to sign in with this provider. Requires confirm=true. Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyDestructive, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Positional: "", + Args: []catalog.OperationArg{ + {Name: "id", Type: catalog.ArgTypeString, Required: true, Help: "Social provider ID", PositionalOnly: true}, + {Name: "confirm", Type: catalog.ArgTypeBool, Required: true, Help: "Confirm the destructive delete"}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + if !catalog.BoolArg(input, "confirm", false) { + return nil, fmt.Errorf("admin_social_providers_delete: confirmation is required") + } + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + id := catalog.StrArg(input, "id", "") + if id == "" { + return nil, fmt.Errorf("admin_social_providers_delete: provider ID is required") + } + if err := svc.DeleteSocialProvider(ctx, id); err != nil { + return nil, err + } + return &SocialProvidersDeleteResult{Deleted: true, ID: id}, nil + }), + }) +} + +// adminSocialProvidersEnable is the `admin social-providers enable` operation. +func adminSocialProvidersEnable(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_enable", + Title: "Enable a social provider", + Summary: "Enable a social login provider", + Description: "Enable a previously disabled social login provider so users can sign in with it. Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyMutate, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Positional: "", + Args: []catalog.OperationArg{ + {Name: "id", Type: catalog.ArgTypeString, Required: true, Help: "Social provider ID", PositionalOnly: true}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + id := catalog.StrArg(input, "id", "") + if id == "" { + return nil, fmt.Errorf("admin_social_providers_enable: provider ID is required") + } + return svc.EnableSocialProvider(ctx, id) + }), + }) +} + +// adminSocialProvidersDisable is the `admin social-providers disable` +// operation. +func adminSocialProvidersDisable(d AdminDeps) catalog.Operation { + return catalog.NewOperation(catalog.OperationSpec{ + Name: "admin_social_providers_disable", + Title: "Disable a social provider", + Summary: "Disable a social login provider", + Description: "Disable a social login provider so it can no longer be used to authenticate. Requires admin privileges.", + Category: "admin", + Safety: catalog.SafetyMutate, + Interaction: catalog.InteractionAgentSafe, + Visibility: catalog.VisibilityBoth, + Positional: "", + Args: []catalog.OperationArg{ + {Name: "id", Type: catalog.ArgTypeString, Required: true, Help: "Social provider ID", PositionalOnly: true}, + }, + Handler: handler(func(ctx context.Context, input map[string]any) (any, error) { + svc, err := d.socialProviders() + if err != nil { + return nil, err + } + if err := svc.RequireAuthenticated(); err != nil { + return nil, err + } + id := catalog.StrArg(input, "id", "") + if id == "" { + return nil, fmt.Errorf("admin_social_providers_disable: provider ID is required") + } + return svc.DisableSocialProvider(ctx, id) + }), + }) +} diff --git a/internal/catalogops/admin_social_providers_test.go b/internal/catalogops/admin_social_providers_test.go new file mode 100644 index 00000000..2aced956 --- /dev/null +++ b/internal/catalogops/admin_social_providers_test.go @@ -0,0 +1,405 @@ +package catalogops + +import ( + "context" + "strings" + "testing" + + "go.lumeweb.com/pinner-cli/internal/catalog" + coreadmin "go.lumeweb.com/pinner-cli/internal/core/admin" + "go.lumeweb.com/pinner-cli/internal/core/config" + configmocks "go.lumeweb.com/pinner-cli/internal/core/config/mocks" + "go.lumeweb.com/portal-sdk/admin" +) + +// fakeSocialProviderService is a hand-rolled admin.SocialProviderAdminService +// whose methods are driven by function fields, so tests can assert both the +// plumbing (RequireAuthenticated gating, argument forwarding) and the op +// result wrapping without mocks. +type fakeSocialProviderService struct { + requireAuth func() error + listFn func(ctx context.Context) ([]*admin.SocialProvider, int, error) + getFn func(ctx context.Context, id string) (*admin.SocialProvider, error) + createFn func(ctx context.Context, req *admin.SocialProviderRequest) (*admin.SocialProvider, error) + updateFn func(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) + deleteFn func(ctx context.Context, id string) error + enableFn func(ctx context.Context, id string) (*admin.SocialProvider, error) + disableFn func(ctx context.Context, id string) (*admin.SocialProvider, error) +} + +func (f *fakeSocialProviderService) RequireAuthenticated() error { + if f.requireAuth != nil { + return f.requireAuth() + } + return nil +} + +func (f *fakeSocialProviderService) ListSocialProviders(ctx context.Context) ([]*admin.SocialProvider, int, error) { + if f.listFn != nil { + return f.listFn(ctx) + } + return nil, 0, nil +} + +func (f *fakeSocialProviderService) GetSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + if f.getFn != nil { + return f.getFn(ctx, id) + } + return nil, nil +} + +func (f *fakeSocialProviderService) CreateSocialProvider(ctx context.Context, req *admin.SocialProviderRequest) (*admin.SocialProvider, error) { + if f.createFn != nil { + return f.createFn(ctx, req) + } + return nil, nil +} + +func (f *fakeSocialProviderService) UpdateSocialProvider(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) { + if f.updateFn != nil { + return f.updateFn(ctx, id, req) + } + return nil, nil +} + +func (f *fakeSocialProviderService) DeleteSocialProvider(ctx context.Context, id string) error { + if f.deleteFn != nil { + return f.deleteFn(ctx, id) + } + return nil +} + +func (f *fakeSocialProviderService) EnableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + if f.enableFn != nil { + return f.enableFn(ctx, id) + } + return nil, nil +} + +func (f *fakeSocialProviderService) DisableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + if f.disableFn != nil { + return f.disableFn(ctx, id) + } + return nil, nil +} + +// testSocialProvidersDeps wires a fake social provider service into an +// AdminDeps whose CfgMgr returns a fresh config mock. +func testSocialProvidersDeps(t *testing.T, svc *fakeSocialProviderService) AdminDeps { + return AdminDeps{ + CfgMgr: func() config.Manager { return configmocks.NewMockManager(t) }, + SocialProviderAdminService: func(cfgMgr config.Manager) (coreadmin.SocialProviderAdminService, error) { + return svc, nil + }, + } +} + +// sampleSocialProvider returns a provider with a few fields filled in. +func sampleSocialProvider() *admin.SocialProvider { + p := &admin.SocialProvider{} + p.Id = 3 + p.ProviderId = "github" + p.DisplayName = "GitHub" + p.Enabled = true + p.OrderIndex = 2 + p.Scopes = []string{"read:user", "user:email"} + return p +} + +// TestAdminOperationsReturnsSocialProviders asserts the provider registers the +// social provider operations. +func TestAdminOperationsReturnsSocialProviders(t *testing.T) { + ops := AdminOperations(AdminDeps{}) + names := map[string]bool{} + for _, op := range ops { + names[op.Name()] = true + } + for _, want := range []string{ + "admin_social_providers_list", + "admin_social_providers_get", + "admin_social_providers_create", + "admin_social_providers_update", + "admin_social_providers_delete", + "admin_social_providers_enable", + "admin_social_providers_disable", + } { + if !names[want] { + t.Fatalf("AdminOperations missing expected op %q", want) + } + } +} + +// TestAdminSocialProvidersListNilDeps asserts an unwired service getter +// degrades to a clear error rather than panicking. +func TestAdminSocialProvidersListNilDeps(t *testing.T) { + op := adminSocialProvidersList(AdminDeps{}) + _, err := op.Handler().Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected an error when the social provider service is not wired") + } +} + +// TestAdminSocialProvidersList verifies gating + result wrapping. +func TestAdminSocialProvidersList(t *testing.T) { + svc := &fakeSocialProviderService{ + requireAuth: func() error { return nil }, + listFn: func(ctx context.Context) ([]*admin.SocialProvider, int, error) { + return []*admin.SocialProvider{sampleSocialProvider()}, 1, nil + }, + } + op := adminSocialProvidersList(testSocialProvidersDeps(t, svc)) + res, err := op.Handler().Execute(context.Background(), map[string]any{}) + if err != nil { + t.Fatalf("list: %v", err) + } + got, ok := res.(ListResult) + if !ok { + t.Fatalf("unexpected result type %T", res) + } + if got.ListCount() != 1 { + t.Fatalf("unexpected count: %d", got.ListCount()) + } + items, ok := got.ListItems().([]*admin.SocialProvider) + if !ok || len(items) != 1 { + t.Fatalf("unexpected result: %+v", got.ListItems()) + } +} + +// TestAdminSocialProvidersListAuthGate asserts RequireAuthenticated is honored. +func TestAdminSocialProvidersListAuthGate(t *testing.T) { + svc := &fakeSocialProviderService{requireAuth: func() error { return context.Canceled }} + op := adminSocialProvidersList(testSocialProvidersDeps(t, svc)) + _, err := op.Handler().Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected auth gate to reject the call") + } +} + +// TestAdminSocialProvidersCreateForwarding verifies required-field validation +// and that the request reaches the service untouched. +func TestAdminSocialProvidersCreateForwarding(t *testing.T) { + var gotReq *admin.SocialProviderRequest + svc := &fakeSocialProviderService{ + requireAuth: func() error { return nil }, + createFn: func(ctx context.Context, req *admin.SocialProviderRequest) (*admin.SocialProvider, error) { + gotReq = req + return sampleSocialProvider(), nil + }, + } + op := adminSocialProvidersCreate(testSocialProvidersDeps(t, svc)) + input := map[string]any{ + "provider-id": "github", + "client-id": "cid", + "client-secret": "shh", + "display-name": "GitHub", + "auth-url": "https://github.com/login/oauth/authorize", + "token-url": "https://github.com/login/oauth/access_token", + "user-url": "https://api.github.com/user", + "scopes": []string{"read:user"}, + "user-id-key": "id", + "user-email-key": "email", + "user-name-key": "name", + "order-index": 2, + "enabled": true, + } + _, err := op.Handler().Execute(context.Background(), input) + if err != nil { + t.Fatalf("create: %v", err) + } + if gotReq == nil { + t.Fatal("create request did not reach the service") + } + if gotReq.ProviderId != "github" || gotReq.ClientSecret != "shh" || gotReq.OrderIndex != 2 || !gotReq.Enabled { + t.Fatalf("unexpected request: %+v", gotReq) + } +} + +// TestAdminSocialProvidersCreateValidation asserts the required-field errors +// fire before the service is called. +func TestAdminSocialProvidersCreateValidation(t *testing.T) { + cases := []struct { + key, missing string + }{ + {"provider-id", "provider-id"}, + {"client-id", "client-id"}, + {"client-secret", "client-secret"}, + {"display-name", "display-name"}, + } + for _, tc := range cases { + op := adminSocialProvidersCreate(testSocialProvidersDeps(t, &fakeSocialProviderService{})) + input := map[string]any{ + "provider-id": "github", + "client-id": "cid", + "client-secret": "shh", + "display-name": "GitHub", + } + delete(input, tc.key) + _, err := op.Handler().Execute(context.Background(), input) + if err == nil || !strings.Contains(err.Error(), tc.missing) { + t.Fatalf("expected error mentioning %q, got %v", tc.missing, err) + } + } +} + +// TestAdminSocialProvidersUpdatePatchOnlySuppliedFields asserts supplied args +// map to the patch request's pointer fields while everything else stays nil, +// so the backend leaves omitted fields unchanged. +func TestAdminSocialProvidersUpdatePatchOnlySuppliedFields(t *testing.T) { + var gotReq *admin.SocialProviderUpdateRequest + var gotID string + svc := &fakeSocialProviderService{ + requireAuth: func() error { return nil }, + updateFn: func(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) { + gotID, gotReq = id, req + return sampleSocialProvider(), nil + }, + } + op := adminSocialProvidersUpdate(testSocialProvidersDeps(t, svc)) + _, err := op.Handler().Execute(context.Background(), map[string]any{ + "id": "3", + "display-name": "GitHub (updated)", + "enabled": false, + "order-index": 5, + "scopes": []string{"user:email"}, + "client-secret": "rotated", + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if gotID != "3" { + t.Fatalf("unexpected id %q", gotID) + } + if gotReq == nil { + t.Fatal("update request did not reach the service") + } + if gotReq.DisplayName == nil || *gotReq.DisplayName != "GitHub (updated)" { + t.Fatalf("override not applied: %+v", gotReq) + } + if gotReq.Enabled == nil || *gotReq.Enabled != false { + t.Fatalf("explicit disable not applied: %+v", gotReq) + } + if gotReq.OrderIndex == nil || *gotReq.OrderIndex != 5 { + t.Fatalf("order index not applied: %+v", gotReq) + } + if gotReq.Scopes == nil || len(*gotReq.Scopes) != 1 || (*gotReq.Scopes)[0] != "user:email" { + t.Fatalf("scopes not applied: %+v", gotReq) + } + if gotReq.ClientSecret == nil || *gotReq.ClientSecret != "rotated" { + t.Fatalf("secret not applied: %+v", gotReq) + } + // Everything not supplied stays nil on the wire. + if gotReq.ProviderId != nil || gotReq.ClientId != nil || gotReq.AuthUrl != nil { + t.Fatalf("omitted fields must not be set: %+v", gotReq) + } +} + +// TestAdminSocialProvidersUpdateOmittedScopesKeepExisting guards the slice +// edge: an omitted scopes arg normalizes to an empty (non-nil) []string, and +// forwarding that on the patch would erase all scopes the provider needs. The +// update must leave Scopes nil when no scopes were supplied. +func TestAdminSocialProvidersUpdateOmittedScopesKeepExisting(t *testing.T) { + var gotReq *admin.SocialProviderUpdateRequest + svc := &fakeSocialProviderService{ + updateFn: func(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) { + gotReq = req + return sampleSocialProvider(), nil + }, + } + op := adminSocialProvidersUpdate(testSocialProvidersDeps(t, svc)) + // Normalize like Catalog.Invoke does: this is the step that coerces an + // omitted slice arg to a non-nil empty []string, so the regression is only + // visible through the same path production dispatch takes. + normalized, err := catalog.NormalizeOperationInput(op, map[string]any{"id": "3"}) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if _, err := op.Handler().Execute(context.Background(), normalized); err != nil { + t.Fatalf("update: %v", err) + } + if gotReq.Scopes != nil { + t.Fatalf("omitted scopes must stay nil, got %+v", gotReq.Scopes) + } +} + +// TestAdminSocialProvidersUpdateEmptyPatch asserts an update with no editable +// args still succeeds (the empty patch leaves the provider untouched). +func TestAdminSocialProvidersUpdateEmptyPatch(t *testing.T) { + called := false + svc := &fakeSocialProviderService{ + updateFn: func(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) { + called = true + return sampleSocialProvider(), nil + }, + } + op := adminSocialProvidersUpdate(testSocialProvidersDeps(t, svc)) + _, err := op.Handler().Execute(context.Background(), map[string]any{"id": "3"}) + if err != nil { + t.Fatalf("empty patch must be accepted: %v", err) + } + if !called { + t.Fatal("service must be called for an empty patch") + } +} + +// TestAdminSocialProvidersDelete asserts the confirm gate and result shape. +func TestAdminSocialProvidersDelete(t *testing.T) { + var gotID string + svc := &fakeSocialProviderService{ + deleteFn: func(ctx context.Context, id string) error { + gotID = id + return nil + }, + } + op := adminSocialProvidersDelete(testSocialProvidersDeps(t, svc)) + + _, err := op.Handler().Execute(context.Background(), map[string]any{"id": "3"}) + if err == nil || !strings.Contains(err.Error(), "confirmation is required") { + t.Fatalf("expected confirmation error, got %v", err) + } + res, err := op.Handler().Execute(context.Background(), map[string]any{"id": "3", "confirm": true}) + if err != nil { + t.Fatalf("delete: %v", err) + } + if gotID != "3" { + t.Fatalf("unexpected id %q", gotID) + } + dr, ok := res.(*SocialProvidersDeleteResult) + if !ok || !dr.Deleted || dr.ID != "3" { + t.Fatalf("unexpected result: %+v", res) + } +} + +// TestAdminSocialProvidersEnableDisableForwarding verifies the id reaches the +// service and the SDK provider result passes through. +func TestAdminSocialProvidersEnableDisableForwarding(t *testing.T) { + var enableID, disableID string + svc := &fakeSocialProviderService{ + enableFn: func(ctx context.Context, id string) (*admin.SocialProvider, error) { + enableID = id + return sampleSocialProvider(), nil + }, + disableFn: func(ctx context.Context, id string) (*admin.SocialProvider, error) { + disableID = id + return sampleSocialProvider(), nil + }, + } + opts := testSocialProvidersDeps(t, svc) + + en, err := adminSocialProvidersEnable(opts).Handler().Execute(context.Background(), map[string]any{"id": "3"}) + if err != nil { + t.Fatalf("enable: %v", err) + } + if enableID != "3" { + t.Fatalf("enable id %q", enableID) + } + if _, ok := en.(*admin.SocialProvider); !ok { + t.Fatalf("unexpected enable result %T", en) + } + + if _, err := adminSocialProvidersDisable(opts).Handler().Execute(context.Background(), map[string]any{"id": "4"}); err != nil { + t.Fatalf("disable: %v", err) + } + if disableID != "4" { + t.Fatalf("disable id %q", disableID) + } +} diff --git a/internal/cli/admin.go b/internal/cli/admin.go index f3836ad4..1f2fe11c 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -31,6 +31,10 @@ Profiling operations include: - Configure block and mutex profiling rates - View profiling status +Social provider operations include: + - List, create, update, delete social login providers + - Enable/disable providers for login + Examples: pinner admin quota plans list pinner admin quota allowances list @@ -44,6 +48,7 @@ Examples: newAdminWebsitesCommand(), newAdminPprofCommand(), newAdminPlatformDomainsCommand(), + newAdminSocialProvidersCommand(), }, } } @@ -61,4 +66,3 @@ func newQuotaCommand() *cli.Command { func newBillingCommand() *cli.Command { return newAdminBillingCatalogCommand() } - diff --git a/internal/cli/admin_social_providers.go b/internal/cli/admin_social_providers.go new file mode 100644 index 00000000..d5f8d462 --- /dev/null +++ b/internal/cli/admin_social_providers.go @@ -0,0 +1,10 @@ +package cli + +import "github.com/urfave/cli/v3" + +// newAdminSocialProvidersCommand returns the admin social-providers command. +// It is compiled from the operation catalog in catalog_admin_wiring.go, so the +// CLI command tree and the MCP tool surface share one source of truth. +func newAdminSocialProvidersCommand() *cli.Command { + return newAdminSocialProvidersCatalogCommand() +} diff --git a/internal/cli/admin_social_providers_test.go b/internal/cli/admin_social_providers_test.go new file mode 100644 index 00000000..fb3c3f55 --- /dev/null +++ b/internal/cli/admin_social_providers_test.go @@ -0,0 +1,39 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAdminSocialProvidersTree asserts the social-providers command compiled +// from the operation catalog exposes the seven subcommands (list, get, create, +// update, delete, enable, disable), matching the MCP admin_social_providers_* +// tools. +func TestAdminSocialProvidersTree(t *testing.T) { + cmd := newAdminSocialProvidersCommand() + require.NotNil(t, cmd) + assert.Equal(t, "social-providers", cmd.Name) + + names := getSubcommandNames(cmd) + for _, want := range []string{"list", "get", "create", "update", "delete", "enable", "disable"} { + assert.Contains(t, names, want) + } +} + +// TestAdminSocialProvidersMountedUnderAdmin asserts the social-providers +// section is mounted on the admin parent command so the CLI tree and the +// catalog agree. +func TestAdminSocialProvidersMountedUnderAdmin(t *testing.T) { + admin := newAdminCommand() + names := map[string]bool{} + for _, sub := range admin.Commands { + names[sub.Name] = true + } + for _, want := range []string{"quota", "billing", "websites", "pprof", "platform-domains", "social-providers"} { + if !names[want] { + t.Fatalf("admin parent missing expected section %q", want) + } + } +} diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 2f311753..34e004ed 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -22,7 +22,7 @@ func TestNewAdminCommand(t *testing.T) { cmd := newAdminCommand() require.NotNil(t, cmd.Commands) - assert.Len(t, cmd.Commands, 5) + assert.Len(t, cmd.Commands, 6) subcommandNames := getSubcommandNames(cmd) assert.Contains(t, subcommandNames, "quota") @@ -30,6 +30,7 @@ func TestNewAdminCommand(t *testing.T) { assert.Contains(t, subcommandNames, "websites") assert.Contains(t, subcommandNames, "pprof") assert.Contains(t, subcommandNames, "platform-domains") + assert.Contains(t, subcommandNames, "social-providers") }) } diff --git a/internal/cli/catalog_admin_wiring.go b/internal/cli/catalog_admin_wiring.go index 76daaaee..80e0f8f7 100644 --- a/internal/cli/catalog_admin_wiring.go +++ b/internal/cli/catalog_admin_wiring.go @@ -64,6 +64,12 @@ func catalogAdminDeps() catalogops.AdminDeps { } return coreadmin.DefaultBillingAdminServiceFactory(cfgMgr), nil }, + SocialProviderAdminService: func(cfgMgr config.Manager) (coreadmin.SocialProviderAdminService, error) { + if cfgMgr == nil { + return nil, fmt.Errorf("no config manager available") + } + return coreadmin.DefaultSocialProviderAdminServiceFactory(cfgMgr), nil + }, } } @@ -84,6 +90,13 @@ func newAdminWebsitesCatalogCommand() *cli.Command { return newAdminSectionCommand("admin_websites_", CmdWebsites, "Manage IPFS websites (admin)") } +// newAdminSocialProvidersCatalogCommand compiles the admin social-providers +// catalog operations and returns the `social-providers` command to mount under +// the `admin` parent: list, get, create, update, delete, enable, disable. +func newAdminSocialProvidersCatalogCommand() *cli.Command { + return newAdminSectionCommand("admin_social_providers_", CmdSocialProviders, "Manage social login providers") +} + // adminSectionGroup maps an admin sub-op prefix (after the section prefix) to // its CLI subgroup command name. Group and leaf segments can both span multiple // underscore tokens (user_configs_list, plans_set_default), so the group is diff --git a/internal/cli/catalog_deps.go b/internal/cli/catalog_deps.go index aed95138..04ac1f5b 100644 --- a/internal/cli/catalog_deps.go +++ b/internal/cli/catalog_deps.go @@ -155,6 +155,12 @@ func buildCatalogOpsDeps(factory ...ConfigManagerFactory) *mcpadapter.CatalogDep } return admin.DefaultWebsiteAdminServiceFactory(cfgMgr), nil }, + SocialProviderAdminService: func(cfgMgr config.Manager) (admin.SocialProviderAdminService, error) { + if cfgMgr == nil { + return nil, fmt.Errorf("no config manager available") + } + return admin.DefaultSocialProviderAdminServiceFactory(cfgMgr), nil + }, }, } } diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 0603772a..d8ec1ede 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -194,6 +194,7 @@ const ( CmdAdmin = "admin" CmdPprof = "pprof" CmdPlatformDomains = "platform-domains" + CmdSocialProviders = "social-providers" CmdSetBlockRate = "set-block-rate" CmdSetMutexFraction = "set-mutex-fraction" CmdIndex = "index" diff --git a/internal/core/admin/social_provider.go b/internal/core/admin/social_provider.go new file mode 100644 index 00000000..c71d6afb --- /dev/null +++ b/internal/core/admin/social_provider.go @@ -0,0 +1,146 @@ +package admin + +import ( + "context" + "sync" + + "go.lumeweb.com/pinner-cli/internal/core/config" + "go.lumeweb.com/portal-sdk/admin" +) + +// socialProviderAdminService implements the SocialProviderAdminService +// interface using the admin.SocialProviderService. +type socialProviderAdminService struct { + mu sync.RWMutex + base *adminServiceBase + service *admin.SocialProviderService +} + +// SocialProviderAdminServiceFactory creates a SocialProviderAdminService with dependencies. +type SocialProviderAdminServiceFactory func(cfgMgr config.Manager) SocialProviderAdminService + +// DefaultSocialProviderAdminServiceFactory creates a default SocialProviderAdminService instance. +func DefaultSocialProviderAdminServiceFactory(cfgMgr config.Manager) SocialProviderAdminService { + return NewSocialProviderAdminService(cfgMgr, cfgMgr.Config().GetAdminEndpoint()) +} + +// NewSocialProviderAdminService creates a new SocialProviderAdminService instance. +func NewSocialProviderAdminService(cfgMgr config.Manager, apiEndpoint string) SocialProviderAdminService { + return &socialProviderAdminService{ + base: newAdminServiceBase(cfgMgr, apiEndpoint), + } +} + +// SocialProviderAdminService defines the interface for social provider admin +// operations (social login provider configuration management). +type SocialProviderAdminService interface { + RequireAuthenticated() error + + // ListSocialProviders lists all configured social login providers. The + // client secret is never returned by the API. + ListSocialProviders(ctx context.Context) ([]*admin.SocialProvider, int, error) + + // CreateSocialProvider creates a new social login provider configuration. + CreateSocialProvider(ctx context.Context, req *admin.SocialProviderRequest) (*admin.SocialProvider, error) + + // GetSocialProvider returns a single social login provider by ID. The + // client secret is never returned by the API. + GetSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) + + // UpdateSocialProvider patches a social login provider configuration. + // Omitted fields are left unchanged; an omitted client secret keeps the + // stored one. + UpdateSocialProvider(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) + + // DeleteSocialProvider removes a social login provider configuration. + DeleteSocialProvider(ctx context.Context, id string) error + + // EnableSocialProvider re-enables a disabled social login provider. + EnableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) + + // DisableSocialProvider disables a social login provider so it can no + // longer be used to authenticate. + DisableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) +} + +// RequireAuthenticated checks if the admin service is authenticated. +func (s *socialProviderAdminService) RequireAuthenticated() error { + return s.base.RequireAuthenticated() +} + +// getService returns the social provider service, lazily initializing with token exchange if needed. +func (s *socialProviderAdminService) getService(ctx context.Context) (*admin.SocialProviderService, error) { + s.mu.RLock() + if s.service != nil { + s.mu.RUnlock() + return s.service, nil + } + s.mu.RUnlock() + + token, err := s.base.tokenProvider.GetLoginToken(ctx) + if err != nil { + return nil, err + } + + client, err := admin.NewClient( + admin.WithEndpoint(s.base.endpoint), + admin.WithJWT(token), + ) + if err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + s.service = client.SocialProviders() + return s.service, nil +} + +// ListSocialProviders lists all configured social login providers. +func (s *socialProviderAdminService) ListSocialProviders(ctx context.Context) ([]*admin.SocialProvider, int, error) { + return with3(s, ctx, func(svc *admin.SocialProviderService) ([]*admin.SocialProvider, int, error) { + return svc.ListSocialProviders(ctx) + }) +} + +// CreateSocialProvider creates a new social login provider configuration. +func (s *socialProviderAdminService) CreateSocialProvider(ctx context.Context, req *admin.SocialProviderRequest) (*admin.SocialProvider, error) { + return with2(s, ctx, func(svc *admin.SocialProviderService) (*admin.SocialProvider, error) { + return svc.CreateSocialProvider(ctx, req) + }) +} + +// GetSocialProvider retrieves a social login provider by ID. +func (s *socialProviderAdminService) GetSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + return with2(s, ctx, func(svc *admin.SocialProviderService) (*admin.SocialProvider, error) { + return svc.GetSocialProvider(ctx, id) + }) +} + +// UpdateSocialProvider patches a social login provider configuration. +func (s *socialProviderAdminService) UpdateSocialProvider(ctx context.Context, id string, req *admin.SocialProviderUpdateRequest) (*admin.SocialProvider, error) { + return with2(s, ctx, func(svc *admin.SocialProviderService) (*admin.SocialProvider, error) { + return svc.UpdateSocialProvider(ctx, id, req) + }) +} + +// DeleteSocialProvider removes a social login provider configuration. +func (s *socialProviderAdminService) DeleteSocialProvider(ctx context.Context, id string) error { + return with0(s, ctx, func(svc *admin.SocialProviderService) error { + return svc.DeleteSocialProvider(ctx, id) + }) +} + +// EnableSocialProvider enables a previously disabled social login provider. +func (s *socialProviderAdminService) EnableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + return with2(s, ctx, func(svc *admin.SocialProviderService) (*admin.SocialProvider, error) { + return svc.EnableSocialProvider(ctx, id) + }) +} + +// DisableSocialProvider disables a social login provider. +func (s *socialProviderAdminService) DisableSocialProvider(ctx context.Context, id string) (*admin.SocialProvider, error) { + return with2(s, ctx, func(svc *admin.SocialProviderService) (*admin.SocialProvider, error) { + return svc.DisableSocialProvider(ctx, id) + }) +}