mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 12:39:54 +00:00
Adds a new "private" service mode for the reverse proxy: services reachable exclusively over the embedded WireGuard tunnel, gated by per-peer group membership instead of operator auth schemes. Wire contract - ProxyMapping.private (field 13): the proxy MUST call ValidateTunnelPeer and fail closed; operator schemes are bypassed. - ProxyCapabilities.private (4) + supports_private_service (5): capability gate. Management never streams private mappings to proxies that don't claim the capability; the broadcast path applies the same filter via filterMappingsForProxy. - ValidateTunnelPeer RPC: resolves an inbound tunnel IP to a peer, checks the peer's groups against service.AccessGroups, and mints a session JWT on success. checkPeerGroupAccess fails closed when a private service has empty AccessGroups. - ValidateSession/ValidateTunnelPeer responses now carry peer_group_ids + peer_group_names so the proxy can authorise policy-aware middlewares without an extra management round-trip. - ProxyInboundListener + SendStatusUpdate.inbound_listener: per-account inbound listener state surfaced to dashboards. - PathTargetOptions.direct_upstream (11): bypass the embedded NetBird client and dial the target via the proxy host's network stack for upstreams reachable without WireGuard. Data model - Service.Private (bool) + Service.AccessGroups ([]string, JSON- serialised). Validate() rejects bearer auth on private services. Copy() deep-copies AccessGroups. pgx getServices loads the columns. - DomainConfig.Private threaded into the proxy auth middleware. Request handler routes private services through forwardWithTunnelPeer and returns 403 on validation failure. - Account-level SynthesizePrivateServiceZones (synthetic DNS) and injectPrivateServicePolicies (synthetic ACL) gate on len(svc.AccessGroups) > 0. Proxy - /netbird proxy --private (embedded mode) flag; Config.Private in proxy/lifecycle.go. - Per-account inbound listener (proxy/inbound.go) binding HTTP/HTTPS on the embedded NetBird client's WireGuard tunnel netstack. - proxy/internal/auth/tunnel_cache: ValidateTunnelPeer response cache with single-flight de-duplication and per-account eviction. - Local peerstore short-circuit: when the inbound IP isn't in the account roster, deny fast without an RPC. - proxy/server.go reports SupportsPrivateService=true and redacts the full ProxyMapping JSON from info logs (auth_token + header-auth hashed values now only at debug level). Identity forwarding - ValidateSessionJWT returns user_id, email, method, groups, group_names. sessionkey.Claims carries Email + Groups + GroupNames so the proxy can stamp identity onto upstream requests without an extra management round-trip on every cookie-bearing request. - CapturedData carries userEmail / userGroups / userGroupNames; the proxy stamps X-NetBird-User and X-NetBird-Groups on r.Out from the authenticated identity (strips client-supplied values first to prevent spoofing). - AccessLog.UserGroups: access-log enrichment captures the user's group memberships at write time so the dashboard can render group context without reverse-resolving stale memberships. OpenAPI/dashboard surface - ReverseProxyService gains private + access_groups; ReverseProxyCluster gains private + supports_private. ReverseProxyTarget target_type enum gains "cluster". ServiceTargetOptions gains direct_upstream. ProxyAccessLog gains user_groups.
520 lines
16 KiB
Go
520 lines
16 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
nbdns "github.com/netbirdio/netbird/dns"
|
|
"github.com/netbirdio/netbird/idp/dex"
|
|
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
|
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
|
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
|
"github.com/netbirdio/netbird/management/server/posture"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
type mockDatasource struct{}
|
|
|
|
// GetAllConnectedPeers returns a map of connected peer IDs for use in tests with predefined information
|
|
func (mockDatasource) GetAllConnectedPeers() map[string]struct{} {
|
|
return map[string]struct{}{
|
|
"1": {},
|
|
}
|
|
}
|
|
|
|
// GetAllAccounts returns a list of *server.Account for use in tests with predefined information
|
|
func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
|
|
localUserID := dex.EncodeDexUserID("10", "local")
|
|
idpUserID := dex.EncodeDexUserID("20", "zitadel-d5uv82dra0haedlf6kv0")
|
|
oidcUserID := dex.EncodeDexUserID("30", "d6jvvp69kmnc73c9pl40")
|
|
return []*types.Account{
|
|
{
|
|
Id: "1",
|
|
Settings: &types.Settings{PeerLoginExpirationEnabled: true},
|
|
SetupKeys: map[string]*types.SetupKey{
|
|
"1": {
|
|
Id: "1",
|
|
Ephemeral: true,
|
|
UsedTimes: 1,
|
|
},
|
|
},
|
|
Groups: map[string]*types.Group{
|
|
"1": {},
|
|
"2": {},
|
|
},
|
|
NameServerGroups: map[string]*nbdns.NameServerGroup{
|
|
"1": {},
|
|
},
|
|
Peers: map[string]*nbpeer.Peer{
|
|
"1": {
|
|
ID: "1",
|
|
UserID: "test",
|
|
SSHEnabled: false,
|
|
Meta: nbpeer.PeerSystemMeta{GoOS: "linux", WtVersion: "0.0.1", Flags: nbpeer.Flags{ServerSSHAllowed: true, RosenpassEnabled: true}},
|
|
},
|
|
},
|
|
Policies: []*types.Policy{
|
|
{
|
|
Rules: []*types.PolicyRule{
|
|
{
|
|
Bidirectional: true,
|
|
Protocol: types.PolicyRuleProtocolTCP,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Rules: []*types.PolicyRule{
|
|
{
|
|
Bidirectional: false,
|
|
Protocol: types.PolicyRuleProtocolTCP,
|
|
},
|
|
},
|
|
SourcePostureChecks: []string{"1"},
|
|
},
|
|
},
|
|
Routes: map[route.ID]*route.Route{
|
|
"1": {
|
|
ID: "1",
|
|
PeerGroups: make([]string, 1),
|
|
},
|
|
},
|
|
PostureChecks: []*posture.Checks{
|
|
{
|
|
ID: "1",
|
|
Name: "test",
|
|
Checks: posture.ChecksDefinition{
|
|
NBVersionCheck: &posture.NBVersionCheck{
|
|
MinVersion: "0.0.1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "2",
|
|
Name: "tes2",
|
|
Checks: posture.ChecksDefinition{
|
|
NBVersionCheck: &posture.NBVersionCheck{
|
|
MinVersion: "0.0.2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Users: map[string]*types.User{
|
|
"1": {
|
|
Id: "1",
|
|
IsServiceUser: true,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
"1": {},
|
|
},
|
|
},
|
|
localUserID: {
|
|
Id: localUserID,
|
|
IsServiceUser: false,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
"1": {},
|
|
},
|
|
},
|
|
},
|
|
Services: []*rpservice.Service{
|
|
{
|
|
ID: "svc1",
|
|
Enabled: true,
|
|
Targets: []*rpservice.Target{
|
|
{TargetType: "peer"},
|
|
{TargetType: "host", Options: rpservice.TargetOptions{DirectUpstream: true}},
|
|
},
|
|
Auth: rpservice.AuthConfig{
|
|
PasswordAuth: &rpservice.PasswordAuthConfig{Enabled: true},
|
|
},
|
|
Meta: rpservice.Meta{Status: string(rpservice.StatusActive)},
|
|
},
|
|
{
|
|
ID: "svc2",
|
|
Enabled: false,
|
|
Targets: []*rpservice.Target{
|
|
{TargetType: "domain"},
|
|
},
|
|
Auth: rpservice.AuthConfig{
|
|
BearerAuth: &rpservice.BearerAuthConfig{Enabled: true},
|
|
},
|
|
Meta: rpservice.Meta{Status: string(rpservice.StatusPending)},
|
|
},
|
|
{
|
|
ID: "svc3-private",
|
|
Enabled: true,
|
|
Private: true,
|
|
AccessGroups: []string{"grp-eng", "grp-ops"},
|
|
Targets: []*rpservice.Target{
|
|
{TargetType: "cluster", Options: rpservice.TargetOptions{DirectUpstream: true}},
|
|
},
|
|
Meta: rpservice.Meta{Status: string(rpservice.StatusActive)},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Id: "2",
|
|
Settings: &types.Settings{PeerLoginExpirationEnabled: true},
|
|
SetupKeys: map[string]*types.SetupKey{
|
|
"1": {
|
|
Id: "1",
|
|
Ephemeral: true,
|
|
UsedTimes: 1,
|
|
},
|
|
},
|
|
Groups: map[string]*types.Group{
|
|
"1": {},
|
|
"2": {},
|
|
},
|
|
NameServerGroups: map[string]*nbdns.NameServerGroup{
|
|
"1": {},
|
|
},
|
|
Peers: map[string]*nbpeer.Peer{
|
|
"1": {
|
|
ID: "1",
|
|
UserID: "test",
|
|
SSHEnabled: true,
|
|
Meta: nbpeer.PeerSystemMeta{GoOS: "linux", WtVersion: "0.0.1"},
|
|
},
|
|
},
|
|
Policies: []*types.Policy{
|
|
{
|
|
Rules: []*types.PolicyRule{
|
|
{
|
|
Bidirectional: true,
|
|
Protocol: types.PolicyRuleProtocolTCP,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Rules: []*types.PolicyRule{
|
|
{
|
|
Bidirectional: false,
|
|
Protocol: types.PolicyRuleProtocolTCP,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Routes: map[route.ID]*route.Route{
|
|
"1": {
|
|
ID: "1",
|
|
PeerGroups: make([]string, 1),
|
|
},
|
|
},
|
|
Users: map[string]*types.User{
|
|
"1": {
|
|
Id: "1",
|
|
IsServiceUser: true,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
"1": {},
|
|
},
|
|
},
|
|
idpUserID: {
|
|
Id: idpUserID,
|
|
IsServiceUser: false,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
"1": {},
|
|
},
|
|
},
|
|
oidcUserID: {
|
|
Id: oidcUserID,
|
|
IsServiceUser: false,
|
|
PATs: map[string]*types.PersonalAccessToken{
|
|
"1": {},
|
|
},
|
|
},
|
|
},
|
|
Networks: []*networkTypes.Network{
|
|
{
|
|
ID: "1",
|
|
AccountID: "1",
|
|
},
|
|
},
|
|
NetworkResources: []*resourceTypes.NetworkResource{
|
|
{
|
|
ID: "1",
|
|
AccountID: "1",
|
|
NetworkID: "1",
|
|
},
|
|
{
|
|
ID: "2",
|
|
AccountID: "1",
|
|
NetworkID: "1",
|
|
},
|
|
},
|
|
NetworkRouters: []*routerTypes.NetworkRouter{
|
|
{
|
|
ID: "1",
|
|
AccountID: "1",
|
|
NetworkID: "1",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetStoreEngine returns FileStoreEngine
|
|
func (mockDatasource) GetStoreEngine() types.Engine {
|
|
return types.FileStoreEngine
|
|
}
|
|
|
|
// GetCustomDomainsCounts returns test custom domain counts.
|
|
func (mockDatasource) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) {
|
|
return 3, 2, nil
|
|
}
|
|
|
|
// GetProxyMetrics returns canned proxy/cluster counts so the
|
|
// generateProperties test can assert the BYOP signals end-to-end.
|
|
func (mockDatasource) GetProxyMetrics(_ context.Context) (store.ProxyMetrics, error) {
|
|
return store.ProxyMetrics{
|
|
Clusters: 3,
|
|
ClustersBYOP: 1,
|
|
ClustersPrivate: 1,
|
|
Proxies: 4,
|
|
ProxiesConnected: 2,
|
|
}, nil
|
|
}
|
|
|
|
// TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties
|
|
func TestGenerateProperties(t *testing.T) {
|
|
ds := mockDatasource{}
|
|
worker := Worker{
|
|
dataSource: ds,
|
|
connManager: ds,
|
|
idpManager: EmbeddedType,
|
|
}
|
|
|
|
properties := worker.generateProperties(context.Background())
|
|
|
|
if properties["accounts"] != 2 {
|
|
t.Errorf("expected 2 accounts, got %d", properties["accounts"])
|
|
}
|
|
if properties["peers"] != 2 {
|
|
t.Errorf("expected 2 peers, got %d", properties["peers"])
|
|
}
|
|
if properties["routes"] != 2 {
|
|
t.Errorf("expected 2 routes, got %d", properties["routes"])
|
|
}
|
|
if properties["networks"] != 1 {
|
|
t.Errorf("expected 1 networks, got %d", properties["networks"])
|
|
}
|
|
if properties["network_resources"] != 2 {
|
|
t.Errorf("expected 2 network_resources, got %d", properties["network_resources"])
|
|
}
|
|
if properties["network_routers"] != 1 {
|
|
t.Errorf("expected 1 network_routers, got %d", properties["network_routers"])
|
|
}
|
|
if properties["rules"] != 4 {
|
|
t.Errorf("expected 4 rules, got %d", properties["rules"])
|
|
}
|
|
if properties["users"] != 3 {
|
|
t.Errorf("expected 3 users, got %d", properties["users"])
|
|
}
|
|
if properties["setup_keys_usage"] != 2 {
|
|
t.Errorf("expected 1 setup_keys_usage, got %d", properties["setup_keys_usage"])
|
|
}
|
|
if properties["pats"] != 5 {
|
|
t.Errorf("expected 5 personal_access_tokens, got %d", properties["pats"])
|
|
}
|
|
if properties["peers_ssh_enabled"] != 2 {
|
|
t.Errorf("expected 2 peers_ssh_enabled, got %d", properties["peers_ssh_enabled"])
|
|
}
|
|
if properties["routes_with_routing_groups"] != 2 {
|
|
t.Errorf("expected 2 routes_with_routing_groups, got %d", properties["routes_with_routing_groups"])
|
|
}
|
|
if properties["rules_protocol_tcp"] != 4 {
|
|
t.Errorf("expected 4 rules_protocol_tcp, got %d", properties["rules_protocol_tcp"])
|
|
}
|
|
if properties["rules_direction_oneway"] != 2 {
|
|
t.Errorf("expected 2 rules_direction_oneway, got %d", properties["rules_direction_oneway"])
|
|
}
|
|
|
|
if properties["active_peers_last_day"] != 2 {
|
|
t.Errorf("expected 2 active_peers_last_day, got %d", properties["active_peers_last_day"])
|
|
}
|
|
if properties["min_active_peer_version"] != "0.0.1" {
|
|
t.Errorf("expected 0.0.1 min_active_peer_version, got %s", properties["min_active_peer_version"])
|
|
}
|
|
if properties["max_active_peer_version"] != "0.0.1" {
|
|
t.Errorf("expected 0.0.1 max_active_peer_version, got %s", properties["max_active_peer_version"])
|
|
}
|
|
|
|
if properties["peers_login_expiration_enabled"] != 2 {
|
|
t.Errorf("expected 2 peers_login_expiration_enabled, got %d", properties["peers_login_expiration_enabled"])
|
|
}
|
|
|
|
if properties["service_users"] != 2 {
|
|
t.Errorf("expected 2 service_users, got %d", properties["service_users"])
|
|
}
|
|
|
|
if properties["peer_os_linux"] != 2 {
|
|
t.Errorf("expected 2 peer_os_linux, got %d", properties["peer_os_linux"])
|
|
}
|
|
|
|
if properties["ephemeral_peers_setup_keys"] != 2 {
|
|
t.Errorf("expected 2 ephemeral_peers_setup_keys, got %d", properties["ephemeral_peers_setup_keys_usage"])
|
|
}
|
|
|
|
if properties["ephemeral_peers_setup_keys_usage"] != 2 {
|
|
t.Errorf("expected 2 ephemeral_peers_setup_keys_usage, got %d", properties["ephemeral_peers_setup_keys_usage"])
|
|
}
|
|
|
|
if properties["nameservers"] != 2 {
|
|
t.Errorf("expected 2 nameservers, got %d", properties["nameservers"])
|
|
}
|
|
|
|
if properties["groups"] != 4 {
|
|
t.Errorf("expected 4 groups, got %d", properties["groups"])
|
|
}
|
|
|
|
if properties["user_peers"] != 2 {
|
|
t.Errorf("expected 2 user_peers, got %d", properties["user_peers"])
|
|
}
|
|
|
|
if properties["store_engine"] != types.FileStoreEngine {
|
|
t.Errorf("expected JsonFile, got %s", properties["store_engine"])
|
|
}
|
|
|
|
if properties["rules_with_src_posture_checks"] != 1 {
|
|
t.Errorf("expected 1 rules_with_src_posture_checks, got %d", properties["rules_with_src_posture_checks"])
|
|
}
|
|
|
|
if properties["posture_checks"] != 2 {
|
|
t.Errorf("expected 2 posture_checks, got %d", properties["posture_checks"])
|
|
}
|
|
|
|
if properties["rosenpass_enabled"] != 1 {
|
|
t.Errorf("expected 1 rosenpass_enabled, got %d", properties["rosenpass_enabled"])
|
|
}
|
|
|
|
if properties["active_user_peers_last_day"] != 2 {
|
|
t.Errorf("expected 2 active_user_peers_last_day, got %d", properties["active_user_peers_last_day"])
|
|
}
|
|
|
|
if properties["active_users_last_day"] != 1 {
|
|
t.Errorf("expected 1 active_users_last_day, got %d", properties["active_users_last_day"])
|
|
}
|
|
|
|
if properties["local_users_count"] != 1 {
|
|
t.Errorf("expected 1 local_users_count, got %d", properties["local_users_count"])
|
|
}
|
|
if properties["idp_users_count"] != 2 {
|
|
t.Errorf("expected 2 idp_users_count, got %d", properties["idp_users_count"])
|
|
}
|
|
if properties["embedded_idp_users_local"] != 1 {
|
|
t.Errorf("expected 1 embedded_idp_users_local, got %v", properties["embedded_idp_users_local"])
|
|
}
|
|
if properties["embedded_idp_users_zitadel"] != 1 {
|
|
t.Errorf("expected 1 embedded_idp_users_zitadel, got %v", properties["embedded_idp_users_zitadel"])
|
|
}
|
|
if properties["embedded_idp_users_oidc"] != 1 {
|
|
t.Errorf("expected 1 embedded_idp_users_oidc, got %v", properties["embedded_idp_users_oidc"])
|
|
}
|
|
if properties["embedded_idp_count"] != 3 {
|
|
t.Errorf("expected 3 embedded_idp_count, got %v", properties["embedded_idp_count"])
|
|
}
|
|
|
|
if properties["services"] != 3 {
|
|
t.Errorf("expected 3 services, got %v", properties["services"])
|
|
}
|
|
if properties["services_enabled"] != 2 {
|
|
t.Errorf("expected 2 services_enabled, got %v", properties["services_enabled"])
|
|
}
|
|
if properties["services_targets"] != 4 {
|
|
t.Errorf("expected 4 services_targets, got %v", properties["services_targets"])
|
|
}
|
|
if properties["services_status_active"] != 2 {
|
|
t.Errorf("expected 2 services_status_active, got %v", properties["services_status_active"])
|
|
}
|
|
if properties["services_status_pending"] != 1 {
|
|
t.Errorf("expected 1 services_status_pending, got %v", properties["services_status_pending"])
|
|
}
|
|
if properties["services_status_error"] != 0 {
|
|
t.Errorf("expected 0 services_status_error, got %v", properties["services_status_error"])
|
|
}
|
|
if properties["services_target_type_peer"] != 1 {
|
|
t.Errorf("expected 1 services_target_type_peer, got %v", properties["services_target_type_peer"])
|
|
}
|
|
if properties["services_target_type_host"] != 1 {
|
|
t.Errorf("expected 1 services_target_type_host, got %v", properties["services_target_type_host"])
|
|
}
|
|
if properties["services_target_type_domain"] != 1 {
|
|
t.Errorf("expected 1 services_target_type_domain, got %v", properties["services_target_type_domain"])
|
|
}
|
|
if properties["services_target_type_cluster"] != 1 {
|
|
t.Errorf("expected 1 services_target_type_cluster, got %v", properties["services_target_type_cluster"])
|
|
}
|
|
if properties["services_auth_password"] != 1 {
|
|
t.Errorf("expected 1 services_auth_password, got %v", properties["services_auth_password"])
|
|
}
|
|
if properties["services_auth_oidc"] != 1 {
|
|
t.Errorf("expected 1 services_auth_oidc, got %v", properties["services_auth_oidc"])
|
|
}
|
|
if properties["services_auth_pin"] != 0 {
|
|
t.Errorf("expected 0 services_auth_pin, got %v", properties["services_auth_pin"])
|
|
}
|
|
if properties["services_private"] != 1 {
|
|
t.Errorf("expected 1 services_private, got %v", properties["services_private"])
|
|
}
|
|
if properties["services_private_with_access_groups"] != 1 {
|
|
t.Errorf("expected 1 services_private_with_access_groups, got %v", properties["services_private_with_access_groups"])
|
|
}
|
|
if properties["services_private_access_groups_sum"] != 2 {
|
|
t.Errorf("expected 2 services_private_access_groups_sum, got %v", properties["services_private_access_groups_sum"])
|
|
}
|
|
if properties["services_with_direct_upstream"] != 2 {
|
|
t.Errorf("expected 2 services_with_direct_upstream, got %v", properties["services_with_direct_upstream"])
|
|
}
|
|
if properties["proxy_clusters"] != int64(3) {
|
|
t.Errorf("expected 3 proxy_clusters, got %v", properties["proxy_clusters"])
|
|
}
|
|
if properties["proxy_clusters_byop"] != int64(1) {
|
|
t.Errorf("expected 1 proxy_clusters_byop, got %v", properties["proxy_clusters_byop"])
|
|
}
|
|
if properties["proxy_clusters_private"] != int64(1) {
|
|
t.Errorf("expected 1 proxy_clusters_private, got %v", properties["proxy_clusters_private"])
|
|
}
|
|
if properties["proxies"] != int64(4) {
|
|
t.Errorf("expected 4 proxies, got %v", properties["proxies"])
|
|
}
|
|
if properties["proxies_connected"] != int64(2) {
|
|
t.Errorf("expected 2 proxies_connected, got %v", properties["proxies_connected"])
|
|
}
|
|
if properties["custom_domains"] != int64(3) {
|
|
t.Errorf("expected 3 custom_domains, got %v", properties["custom_domains"])
|
|
}
|
|
if properties["custom_domains_validated"] != int64(2) {
|
|
t.Errorf("expected 2 custom_domains_validated, got %v", properties["custom_domains_validated"])
|
|
}
|
|
}
|
|
|
|
func TestExtractIdpType(t *testing.T) {
|
|
tests := []struct {
|
|
connectorID string
|
|
expected string
|
|
}{
|
|
{"okta-abc123def", "okta"},
|
|
{"zitadel-d5uv82dra0haedlf6kv0", "zitadel"},
|
|
{"entra-xyz789", "entra"},
|
|
{"google-abc123", "google"},
|
|
{"pocketid-abc123", "pocketid"},
|
|
{"microsoft-abc123", "microsoft"},
|
|
{"authentik-abc123", "authentik"},
|
|
{"keycloak-d5uv82dra0haedlf6kv0", "keycloak"},
|
|
{"local", "local"},
|
|
{"d6jvvp69kmnc73c9pl40", "oidc"},
|
|
{"", "oidc"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.connectorID, func(t *testing.T) {
|
|
result := extractIdpType(tt.connectorID)
|
|
if result != tt.expected {
|
|
t.Errorf("extractIdpType(%q) = %q, want %q", tt.connectorID, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|