diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f24ad2809c..35e10f395b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,127 +9,10 @@ permissions: contents: read jobs: - test: - name: Test - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:10.8 - env: - TZ: UTC - ports: - - 5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - postgres-ent: - image: postgres:10.8 - env: - TZ: UTC - ports: - - 5432 - options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - - mysql: - image: mysql:5.7 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: dex - ports: - - 3306 - options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5 - - mysql-ent: - image: mysql:5.7 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: dex - ports: - - 3306 - options: --health-cmd "mysql -proot -e \"show databases;\"" --health-interval 10s --health-timeout 5s --health-retries 5 - - etcd: - image: gcr.io/etcd-development/etcd:v3.5.0 - ports: - - 2379 - env: - ETCD_LISTEN_CLIENT_URLS: http://0.0.0.0:2379 - ETCD_ADVERTISE_CLIENT_URLS: http://0.0.0.0:2379 - options: --health-cmd "ETCDCTL_API=3 etcdctl --endpoints http://localhost:2379 endpoint health" --health-interval 10s --health-timeout 5s --health-retries 5 - - keystone: - image: openio/openstack-keystone:rocky - ports: - - 5000 - - 35357 - options: --health-cmd "curl --fail http://localhost:5000/v3" --health-interval 10s --health-timeout 5s --health-retries 5 - - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version: "1.24" - - - name: Download tool dependencies - run: make deps - - # Ensure that generated files were committed. - # It can help us determine, that the code is in the intermediate state, which should not be tested. - # Thus, heavy jobs like creating a kind cluster and testing / linting will be skipped. - - name: Verify - run: make verify - - - name: Start services - run: docker compose -f docker-compose.test.yaml up -d - - - name: Create kind cluster - uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0 - with: - version: "v0.17.0" - node_image: "kindest/node:v1.25.3@sha256:cd248d1438192f7814fbca8fede13cfe5b9918746dfa12583976158a834fd5c5" - - - name: Test - run: make testall - env: - DEX_MYSQL_DATABASE: dex - DEX_MYSQL_USER: root - DEX_MYSQL_PASSWORD: root - DEX_MYSQL_HOST: 127.0.0.1 - DEX_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} - - DEX_MYSQL_ENT_DATABASE: dex - DEX_MYSQL_ENT_USER: root - DEX_MYSQL_ENT_PASSWORD: root - DEX_MYSQL_ENT_HOST: 127.0.0.1 - DEX_MYSQL_ENT_PORT: ${{ job.services.mysql-ent.ports[3306] }} - - DEX_POSTGRES_DATABASE: postgres - DEX_POSTGRES_USER: postgres - DEX_POSTGRES_PASSWORD: postgres - DEX_POSTGRES_HOST: localhost - DEX_POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} - - DEX_POSTGRES_ENT_DATABASE: postgres - DEX_POSTGRES_ENT_USER: postgres - DEX_POSTGRES_ENT_PASSWORD: postgres - DEX_POSTGRES_ENT_HOST: localhost - DEX_POSTGRES_ENT_PORT: ${{ job.services.postgres-ent.ports[5432] }} - - DEX_ETCD_ENDPOINTS: http://localhost:${{ job.services.etcd.ports[2379] }} - - DEX_LDAP_HOST: localhost - DEX_LDAP_PORT: 3890 - DEX_LDAP_TLS_PORT: 6360 - - DEX_KEYSTONE_URL: http://localhost:${{ job.services.keystone.ports[5000] }} - DEX_KEYSTONE_ADMIN_URL: http://localhost:${{ job.services.keystone.ports[35357] }} - DEX_KEYSTONE_ADMIN_USER: demo - DEX_KEYSTONE_ADMIN_PASS: DEMO_PASS - - DEX_KUBERNETES_CONFIG_PATH: ~/.kube/config + # Call the dedicated keystone workflow + keystone-tests: + name: Keystone Connector Tests + uses: ./.github/workflows/keystone-test.yaml lint: name: Lint diff --git a/.github/workflows/keystone-test.yaml b/.github/workflows/keystone-test.yaml new file mode 100644 index 0000000000..bd6c684c1c --- /dev/null +++ b/.github/workflows/keystone-test.yaml @@ -0,0 +1,53 @@ +name: Keystone Tests + +on: + workflow_call: # Allow this workflow to be called by other workflows + workflow_dispatch: + push: + paths: + - 'connector/keystone/**' + +permissions: + contents: read + +jobs: + keystone-test: + name: Keystone Connector Tests + runs-on: ubuntu-latest + + services: + keystone: + image: openio/openstack-keystone:rocky + ports: + - 5000:5000 + - 35357:35357 + options: --health-cmd "curl --fail http://localhost:5000/v3" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Download dependencies + run: go mod download + + - name: Wait for Keystone to be ready + run: | + timeout 60s bash -c 'until curl -f http://localhost:5000/v3; do sleep 2; done' + + - name: Run Keystone unit tests + run: | + go test ./connector/keystone -v -run "Test(GetHostname|GenerateGroupName|PruneDuplicates|FindGroupByID|HTTPHelpers|GetGroups|CheckIfUserExists|Authenticate_TokenMode)" + + - name: Run Keystone integration tests + env: + DEX_KEYSTONE_URL: http://localhost:5000 + DEX_KEYSTONE_ADMIN_URL: http://localhost:35357 + DEX_KEYSTONE_ADMIN_USER: demo + DEX_KEYSTONE_ADMIN_PASS: DEMO_PASS + run: | + go test ./connector/keystone -v diff --git a/Makefile b/Makefile index 9b2247d8ae..e3258c7bf6 100644 --- a/Makefile +++ b/Makefile @@ -38,8 +38,8 @@ examples: bin/grpc-client bin/example-app ## Build example app. .PHONY: release-binary release-binary: LD_FLAGS = "-w -X main.version=$(VERSION) -extldflags \"-static\"" release-binary: ## Build release binaries (used to build a final container image). - @go build -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex - @go build -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint + @go build -buildvcs=false -o /go/bin/dex -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex + @go build -buildvcs=false -o /go/bin/docker-entrypoint -v -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint bin/dex: @mkdir -p bin/ @@ -53,12 +53,6 @@ bin/example-app: @mkdir -p bin/ @cd examples/ && go install -v -ldflags $(LD_FLAGS) $(REPO_PATH)/examples/example-app -.PHONY: release-binary -release-binary: LD_FLAGS = "-w -X main.version=$(VERSION) -extldflags \"-static\"" -release-binary: generate - @go build -o /go/bin/dex -v -buildvcs=false -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/dex - @go build -o /go/bin/docker-entrypoint -v -buildvcs=false -ldflags $(LD_FLAGS) $(REPO_PATH)/cmd/docker-entrypoint - ##@ Generate .PHONY: generate @@ -68,51 +62,6 @@ generate: generate-proto generate-proto-internal generate-ent go-mod-tidy ## Run generate-ent: ## Generate code for database ORM. @go generate $(REPO_PATH)/storage/ent/ -test: - @go test -v ./... - -testrace: - @go test -v --race ./... - -.PHONY: kind-up kind-down kind-tests -kind-up: - @mkdir -p bin/test - @kind create cluster --image ${KIND_NODE_IMAGE} --kubeconfig ${KIND_TMP_DIR} - -kind-down: - @kind delete cluster - rm ${KIND_TMP_DIR} - -kind-tests: export DEX_KUBERNETES_CONFIG_PATH=${KIND_TMP_DIR} -kind-tests: testall - -.PHONY: lint lint-fix -lint: ## Run linter - golangci-lint run - -.PHONY: fix -fix: ## Fix lint violations - golangci-lint run --fix - -.PHONY: docker-image -docker-image: - docker build -t $(DOCKER_IMAGE) . - -.PHONY: verify-proto -verify-proto: proto - @./scripts/git-diff - -clean: - @rm -rf bin/ - -testall: testrace - -FORCE: - -.PHONY: test testrace testall - -.PHONY: proto -proto: .PHONY: generate-proto generate-proto: ## Generate the Dex client's protobuf code. @protoc --go_out=paths=source_relative:. --go-grpc_out=paths=source_relative:. api/v2/*.proto diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index 9a856bc579..4aba7f5c3d 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -314,7 +314,6 @@ func (p *conn) authenticate(ctx context.Context, username, pass string) (string, req = req.WithContext(ctx) resp, err := p.client.Do(req) - if err != nil { return "", nil, fmt.Errorf("keystone: error %v", err) } @@ -370,7 +369,6 @@ func (p *conn) getAdminTokenUnscoped(ctx context.Context) (string, error) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(ctx) resp, err := p.client.Do(req) - if err != nil { return "", fmt.Errorf("keystone: error %v", err) } diff --git a/connector/keystone/keystone_integration_test.go b/connector/keystone/keystone_integration_test.go new file mode 100644 index 0000000000..ce5b6a0dbe --- /dev/null +++ b/connector/keystone/keystone_integration_test.go @@ -0,0 +1,276 @@ +package keystone + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/dexidp/dex/connector" +) + +// TestKeystoneConnectorIntegration tests the Keystone connector against a real Keystone service +func TestKeystoneConnectorIntegration(t *testing.T) { + // Skip if integration test environment variables are not set + keystoneHost := os.Getenv("DEX_KEYSTONE_URL") + if keystoneHost == "" { + t.Skip("DEX_KEYSTONE_URL not set, skipping integration tests") + } + + // Use default admin credentials that should work with the OpenIO Keystone image + adminUser := os.Getenv("DEX_KEYSTONE_ADMIN_USER") + if adminUser == "" { + adminUser = "admin" + } + + adminPass := os.Getenv("DEX_KEYSTONE_ADMIN_PASS") + if adminPass == "" { + adminPass = "admin" + } + + // Test connector configuration + config := Config{ + Host: keystoneHost, + Domain: "default", + AdminUsername: adminUser, + AdminPassword: adminPass, + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + // Test connector creation + keystoneConnector, err := config.Open("test-keystone", logger) + if err != nil { + t.Fatalf("Failed to create Keystone connector: %v", err) + } + + keystoneConn, ok := keystoneConnector.(*conn) + if !ok { + t.Fatal("Expected Keystone connector type") + } + + ctx := context.Background() + + t.Run("AdminTokenAuthentication", func(t *testing.T) { + // Test admin token retrieval + token, err := keystoneConn.getAdminTokenUnscoped(ctx) + if err != nil { + t.Fatalf("Failed to get admin token: %v", err) + } + if token == "" { + t.Fatal("Expected non-empty admin token") + } + t.Logf("Successfully obtained admin token: %s", token[:10]+"...") + }) + + t.Run("AdminUserLogin", func(t *testing.T) { + // Test admin user login through the connector + scopes := connector.Scopes{ + OfflineAccess: false, + Groups: true, + } + + identity, validPassword, err := keystoneConn.Login(ctx, scopes, adminUser, adminPass) + if err != nil { + t.Fatalf("Admin login failed: %v", err) + } + if !validPassword { + t.Fatal("Expected valid password for admin user") + } + if identity.Username != adminUser { + t.Fatalf("Expected username %s, got %s", adminUser, identity.Username) + } + if identity.UserID == "" { + t.Fatal("Expected non-empty user ID") + } + t.Logf("Admin login successful - Username: %s, UserID: %s, Groups: %v", + identity.Username, identity.UserID, identity.Groups) + }) + + t.Run("TokenModeAuthentication", func(t *testing.T) { + // First get a valid token + token, err := keystoneConn.getAdminTokenUnscoped(ctx) + if err != nil { + t.Fatalf("Failed to get admin token: %v", err) + } + + // Test token-based authentication + scopes := connector.Scopes{ + OfflineAccess: false, + Groups: false, + } + + identity, validPassword, err := keystoneConn.Login(ctx, scopes, "_TOKEN_", token) + if err != nil { + t.Fatalf("Token authentication failed: %v", err) + } + if !validPassword { + t.Fatal("Expected valid token authentication") + } + if identity.Username == "" { + t.Fatal("Expected non-empty username from token") + } + if identity.UserID == "" { + t.Fatal("Expected non-empty user ID from token") + } + t.Logf("Token authentication successful - Username: %s, UserID: %s", + identity.Username, identity.UserID) + }) + + t.Run("InvalidCredentials", func(t *testing.T) { + scopes := connector.Scopes{ + OfflineAccess: false, + Groups: false, + } + + _, validPassword, err := keystoneConn.Login(ctx, scopes, "invalid_user", "invalid_pass") + if err == nil { + t.Fatal("Expected error for invalid credentials") + } + if validPassword { + t.Fatal("Expected invalid password for non-existent user") + } + t.Logf("Invalid credentials correctly rejected: %v", err) + }) + + t.Run("ConnectorRefresh", func(t *testing.T) { + // First perform a login to get connector data + scopes := connector.Scopes{ + OfflineAccess: false, + Groups: true, + } + + identity, validPassword, err := keystoneConn.Login(ctx, scopes, adminUser, adminPass) + if err != nil { + t.Fatalf("Initial login failed: %v", err) + } + if !validPassword { + t.Fatal("Expected valid password for admin user") + } + + // Test refresh functionality + refreshedIdentity, err := keystoneConn.Refresh(ctx, scopes, identity) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + if refreshedIdentity.Username != identity.Username { + t.Fatalf("Expected username %s, got %s", identity.Username, refreshedIdentity.Username) + } + if refreshedIdentity.UserID != identity.UserID { + t.Fatalf("Expected user ID %s, got %s", identity.UserID, refreshedIdentity.UserID) + } + t.Logf("Refresh successful - Username: %s, UserID: %s, Groups: %v", + refreshedIdentity.Username, refreshedIdentity.UserID, refreshedIdentity.Groups) + }) + + t.Run("HelperFunctions", func(t *testing.T) { + // Test hostname extraction + hostname, err := keystoneConn.getHostname() + if err != nil { + t.Fatalf("Failed to get hostname: %v", err) + } + if hostname == "" { + t.Fatal("Expected non-empty hostname") + } + t.Logf("Hostname: %s", hostname) + + // Test admin token for API calls + token, err := keystoneConn.getAdminTokenUnscoped(ctx) + if err != nil { + t.Fatalf("Failed to get admin token: %v", err) + } + + // Test token info retrieval + tokenInfo, err := keystoneConn.getTokenInfo(ctx, token) + if err != nil { + t.Fatalf("Failed to get token info: %v", err) + } + if tokenInfo.User.Name == "" { + t.Fatal("Expected non-empty user name in token info") + } + t.Logf("Token info - User: %s, Domain: %s", tokenInfo.User.Name, tokenInfo.User.Domain.Name) + + // Test user existence check + exists, err := keystoneConn.checkIfUserExists(ctx, tokenInfo.User.ID, token) + if err != nil { + t.Fatalf("Failed to check user existence: %v", err) + } + if !exists { + t.Fatal("Expected admin user to exist") + } + t.Logf("User existence check passed for user ID: %s", tokenInfo.User.ID) + }) +} + +// TestKeystoneConnectorConfiguration tests various configuration scenarios +func TestKeystoneConnectorConfiguration(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + t.Run("MissingHost", func(t *testing.T) { + config := Config{ + Domain: "default", + AdminUsername: "admin", + AdminPassword: "admin", + } + + _, err := config.Open("test", logger) + if err == nil { + t.Fatal("Expected error for missing host") + } + t.Logf("Correctly rejected missing host: %v", err) + }) + + t.Run("ValidConfiguration", func(t *testing.T) { + config := Config{ + Host: "http://localhost:5000", + Domain: "default", + AdminUsername: "admin", + AdminPassword: "admin", + } + + keystoneConnector, err := config.Open("test", logger) + if err != nil { + t.Fatalf("Failed to create connector with valid config: %v", err) + } + + keystoneConn, ok := keystoneConnector.(*conn) + if !ok { + t.Fatal("Expected Keystone connector type") + } + + if keystoneConn.Host != config.Host { + t.Fatalf("Expected host %s, got %s", config.Host, keystoneConn.Host) + } + if keystoneConn.AdminUsername != config.AdminUsername { + t.Fatalf("Expected admin username %s, got %s", config.AdminUsername, keystoneConn.AdminUsername) + } + if keystoneConn.Domain.Name != config.Domain { + t.Fatalf("Expected domain %s, got %s", config.Domain, keystoneConn.Domain.Name) + } + }) + + t.Run("InsecureSkipVerify", func(t *testing.T) { + config := Config{ + Host: "https://localhost:5000", + Domain: "default", + AdminUsername: "admin", + AdminPassword: "admin", + InsecureSkipVerify: true, + } + + keystoneConnector, err := config.Open("test", logger) + if err != nil { + t.Fatalf("Failed to create connector with InsecureSkipVerify: %v", err) + } + + keystoneConn, ok := keystoneConnector.(*conn) + if !ok { + t.Fatal("Expected Keystone connector type") + } + + // Verify TLS config is set for insecure + if keystoneConn.client.Transport == nil { + t.Fatal("Expected HTTP transport to be configured") + } + }) +} diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go index 9b0590df12..0210702aea 100644 --- a/connector/keystone/keystone_test.go +++ b/connector/keystone/keystone_test.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "io" + "log/slog" "net/http" + "net/http/httptest" "os" "reflect" "strings" @@ -23,7 +25,7 @@ const ( testGroup = "test_group" testDomainAltName = "altdomain" testDomainID = "default" - testDomainName = "Default" + testDomainName = "default" ) var ( @@ -69,7 +71,7 @@ func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) Password: password{ User: user{ Name: adminName, - Domain: domainKeystone{ID: testDomainID}, + Domain: domainKeystone{Name: testDomainName}, Password: adminPass, }, }, @@ -309,8 +311,9 @@ func TestIncorrectCredentialsLogin(t *testing.T) { setupVariables(t) c := conn{ client: http.DefaultClient, - Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, + Host: keystoneURL, Domain: domainKeystone{Name: testDomainName}, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: true} _, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass) @@ -355,7 +358,7 @@ func TestValidUserLogin(t *testing.T) { name: "test with email address", input: tUser{ createDomain: false, - domain: domainKeystone{ID: testDomainID}, + domain: domainKeystone{Name: testDomainName}, username: testUser, email: testEmail, password: testPass, @@ -370,7 +373,7 @@ func TestValidUserLogin(t *testing.T) { name: "test without email address", input: tUser{ createDomain: false, - domain: domainKeystone{ID: testDomainID}, + domain: domainKeystone{Name: testDomainName}, username: testUser, email: "", password: testPass, @@ -447,6 +450,7 @@ func TestValidUserLogin(t *testing.T) { client: http.DefaultClient, Host: keystoneURL, Domain: tt.input.domain, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: true} identity, validPW, err := c.Login(context.Background(), s, tt.input.username, tt.input.password) @@ -485,6 +489,7 @@ func TestUseRefreshToken(t *testing.T) { client: http.DefaultClient, Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -511,6 +516,7 @@ func TestUseRefreshTokenUserDeleted(t *testing.T) { client: http.DefaultClient, Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -542,6 +548,7 @@ func TestUseRefreshTokenGroupsChanged(t *testing.T) { client: http.DefaultClient, Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: true} @@ -579,6 +586,7 @@ func TestNoGroupsInScope(t *testing.T) { client: http.DefaultClient, Host: keystoneURL, Domain: domainKeystone{ID: testDomainID}, AdminUsername: adminUser, AdminPassword: adminPass, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } s := connector.Scopes{OfflineAccess: true, Groups: false} @@ -635,3 +643,540 @@ func expectEquals(t *testing.T, a interface{}, b interface{}) { t.Errorf("Expected %v to be equal %v", a, b) } } + +func newNoopLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func TestAuthenticate_TokenMode(t *testing.T) { + // Test Login with token mode (username="_TOKEN_") + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v3/auth/tokens" { + _ = json.NewEncoder(w).Encode(tokenResponse{Token: tokenInfo{User: userKeystone{ID: "u1", Name: "tokenuser"}}}) + } else if strings.HasPrefix(r.URL.Path, "/v3/users/") { + _ = json.NewEncoder(w).Encode(userResponse{User: struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + }{Name: "tokenuser", Email: "tokenuser@example.com", ID: "u1"}}) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{Host: ts.URL, client: ts.Client(), Logger: newNoopLogger()} + + // Test token mode login + identity, valid, err := c.Login(context.Background(), connector.Scopes{}, "_TOKEN_", "some-token") + if err != nil { + t.Fatalf("Login with token failed: %v", err) + } + if !valid { + t.Error("Expected token login to be valid") + } + if identity.Username != "tokenuser" { + t.Errorf("Expected username tokenuser, got %s", identity.Username) + } + if identity.Email != "tokenuser@example.com" { + t.Errorf("Expected email tokenuser@example.com, got %s", identity.Email) + } + if !identity.EmailVerified { + t.Error("Expected email to be verified") + } +} + +func TestGetHostname(t *testing.T) { + tests := []struct { + name string + host string + expected string + wantErr bool + }{ + {"standard https", "https://customer1.example.com:5000", "customer1", false}, + {"http with port", "http://test-host.domain.com:8080", "test-host", false}, + {"no port", "https://myhost.example.org", "myhost", false}, + {"ip address", "http://192.168.1.100:5000", "192", false}, + {"localhost", "http://localhost:5000", "localhost", false}, + {"invalid url", "://invalid-url", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &conn{Host: tt.host, Logger: newNoopLogger()} + got, err := c.getHostname() + if (err != nil) != tt.wantErr { + t.Errorf("getHostname() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.expected { + t.Errorf("getHostname() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGenerateGroupName(t *testing.T) { + c := &conn{Domain: domainKeystone{Name: "Default_Domain"}, Logger: newNoopLogger()} + proj := project{ID: "p1", Name: "My_Project"} + roleMember := role{ID: "r1", Name: "_member_"} + roleAdmin := role{ID: "r2", Name: "admin"} + name1 := c.generateGroupName(proj, roleMember, "cust") + name2 := c.generateGroupName(proj, roleAdmin, "cust") + if name1 != "cust-default-domain-my-project-member" { + t.Fatalf("member name unexpected: %s", name1) + } + if name2 != "cust-default-domain-my-project-admin" { + t.Fatalf("admin name unexpected: %s", name2) + } + + // Test edge cases + projSpecial := project{ID: "p2", Name: "Special_Project_Name"} + roleSpecial := role{ID: "r3", Name: "custom_role"} + name3 := c.generateGroupName(projSpecial, roleSpecial, "customer-name") + expected := "customer-name-default-domain-special-project-name-custom_role" + if name3 != expected { + t.Fatalf("special name unexpected: got %s, want %s", name3, expected) + } +} + +func TestPruneDuplicates(t *testing.T) { + in := []string{"a", "b", "a", "c", "b"} + out := pruneDuplicates(in) + expectEquals(t, []string{"a", "b", "c"}, out) +} + +func TestFindGroupByID(t *testing.T) { + groups := []keystoneGroup{{ID: "1", Name: "g1"}, {ID: "2", Name: "g2"}, {ID: "3", Name: ""}} + + // Test finding existing group + g, ok := findGroupByID(groups, "2") + if !ok || g.Name != "g2" { + t.Fatalf("expected to find g2, got %+v ok=%v", g, ok) + } + + // Test finding group with empty name + g, ok = findGroupByID(groups, "3") + if !ok || g.ID != "3" { + t.Fatalf("expected to find group with ID 3, got %+v ok=%v", g, ok) + } + + // Test not finding non-existent group + _, ok = findGroupByID(groups, "999") + if ok { + t.Fatalf("did not expect to find id 999") + } + + // Test empty groups slice + _, ok = findGroupByID([]keystoneGroup{}, "1") + if ok { + t.Fatalf("did not expect to find anything in empty slice") + } +} + +func TestHTTPHelpers(t *testing.T) { + // Mock Keystone API endpoints used by helpers + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/groups": + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{{ID: "g1", Name: "Group1"}, {ID: "g2", Name: "Group2"}}}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v3/users/") && strings.HasSuffix(r.URL.Path, "/groups"): + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{{ID: "g1", Name: "Group1"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/roles": + _ = json.NewEncoder(w).Encode(struct { + Roles []role `json:"roles"` + }{Roles: []role{{ID: "r1", Name: "admin"}, {ID: "r2", Name: "_member_"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/projects": + _ = json.NewEncoder(w).Encode(struct { + Projects []project `json:"projects"` + }{Projects: []project{{ID: "p1", Name: "Project1", DomainID: "default"}, {ID: "p2", Name: "Project2", DomainID: "default"}}}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v3/users/") && !strings.HasSuffix(r.URL.Path, "/groups"): + _ = json.NewEncoder(w).Encode(userResponse{User: struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + }{Name: "u1", Email: "u1@example.com", ID: "u1"}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/auth/tokens": + _ = json.NewEncoder(w).Encode(tokenResponse{Token: tokenInfo{User: userKeystone{ID: "u1", Name: "u1"}}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "user.id="): + // User role assignments + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{{ + Scope: projectScope{Project: identifierContainer{ID: "p1"}}, + User: identifierContainer{ID: "u1"}, + Role: identifierContainer{ID: "r1"}, + }}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "group.id="): + // Group role assignments + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{{ + Scope: projectScope{Project: identifierContainer{ID: "p2"}}, + Role: identifierContainer{ID: "r2"}, + }}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{Host: ts.URL, client: ts.Client(), Logger: newNoopLogger(), Domain: domainKeystone{Name: "Default"}} + ctx := context.Background() + + // Test getAllGroups + groups, err := c.getAllGroups(ctx, "token") + if err != nil || len(groups) != 2 || groups[0].Name != "Group1" { + t.Fatalf("getAllGroups unexpected: groups=%+v err=%v", groups, err) + } + + // Test getUserGroups + ug, err := c.getUserGroups(ctx, "u1", "token") + if err != nil || len(ug) != 1 || ug[0].ID != "g1" { + t.Fatalf("getUserGroups unexpected: groups=%+v err=%v", ug, err) + } + + // Test getRoles + roles, err := c.getRoles(ctx, "token") + if err != nil || len(roles) != 2 || roles[0].ID != "r1" { + t.Fatalf("getRoles unexpected: roles=%+v err=%v", roles, err) + } + + // Test getProjects + projects, err := c.getProjects(ctx, "token") + if err != nil || len(projects) != 2 || projects[0].ID != "p1" { + t.Fatalf("getProjects unexpected: projects=%+v err=%v", projects, err) + } + + // Test getUser + usr, err := c.getUser(ctx, "u1", "token") + if err != nil || usr == nil || usr.User.Email != "u1@example.com" { + t.Fatalf("getUser unexpected: user=%+v err=%v", usr, err) + } + + // Test getTokenInfo + ti, err := c.getTokenInfo(ctx, "token") + if err != nil || ti == nil || ti.User.ID != "u1" { + t.Fatalf("getTokenInfo unexpected: ti=%+v err=%v", ti, err) + } + + // Test getRoleAssignments for user + ras, err := c.getRoleAssignments(ctx, "token", getRoleAssignmentsOptions{userID: "u1"}) + if err != nil || len(ras) != 1 || ras[0].Role.ID != "r1" { + t.Fatalf("getRoleAssignments for user unexpected: ras=%+v err=%v", ras, err) + } + + // Test getRoleAssignments for group + gras, err := c.getRoleAssignments(ctx, "token", getRoleAssignmentsOptions{groupID: "g1"}) + if err != nil || len(gras) != 1 || gras[0].Role.ID != "r2" { + t.Fatalf("getRoleAssignments for group unexpected: gras=%+v err=%v", gras, err) + } +} + +func TestGetGroups_ComposesUserLocalAndRoleGroups(t *testing.T) { + // Mock comprehensive Keystone endpoints for getGroups testing + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/groups": + // Return all groups including one with empty name for ID resolution + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{ + {ID: "g1", Name: "LocalGroup1"}, + {ID: "g2", Name: "ResolvedGroup2"}, + {ID: "g3", Name: "SSOGroup3"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/roles": + _ = json.NewEncoder(w).Encode(struct { + Roles []role `json:"roles"` + }{Roles: []role{ + {ID: "r1", Name: "admin"}, + {ID: "r2", Name: "_member_"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/projects": + _ = json.NewEncoder(w).Encode(struct { + Projects []project `json:"projects"` + }{Projects: []project{ + {ID: "p1", Name: "Project_One"}, + {ID: "p2", Name: "Project_Two"}, + }}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v3/users/") && strings.HasSuffix(r.URL.Path, "/groups"): + // Local groups for user including one with empty name + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{ + {ID: "g1", Name: "LocalGroup1"}, + {ID: "g2", Name: ""}, // Empty name, should be resolved via findGroupByID + }}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "user.id="): + // User role assignments + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{ + { + Scope: projectScope{Project: identifierContainer{ID: "p1"}}, + User: identifierContainer{ID: "u1"}, + Role: identifierContainer{ID: "r1"}, + }, + }}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "group.id=g1"): + // Group role assignments for g1 + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{ + { + Scope: projectScope{Project: identifierContainer{ID: "p2"}}, + Role: identifierContainer{ID: "r2"}, + }, + }}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "group.id=g2"): + // Group role assignments for g2 + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "group.id=g3"): + // Group role assignments for g3 (SSO group) + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{ + Host: ts.URL, + client: ts.Client(), + Logger: newNoopLogger(), + Domain: domainKeystone{Name: "Test_Domain"}, + CustomerName: "testcust", + } + ctx := context.Background() + + // Create tokenInfo with SSO federation groups (one named, one by ID only) + ti := &tokenInfo{User: userKeystone{ + ID: "u1", + Name: "testuser", + OSFederation: &struct { + Groups []keystoneGroup `json:"groups"` + IdentityProvider struct { + ID string `json:"id"` + } `json:"identity_provider"` + Protocol struct { + ID string `json:"id"` + } `json:"protocol"` + }{Groups: []keystoneGroup{ + {ID: "g3", Name: "SSOGroup3"}, // Named SSO group + {ID: "g1", Name: ""}, // SSO group by ID only, should be resolved + }}, + }} + + groups, err := c.getGroups(ctx, "token", ti) + if err != nil { + t.Fatalf("getGroups error: %v", err) + } + + // Expected groups: + // 1. SSO groups: "SSOGroup3", "LocalGroup1" (resolved from ID) + // 2. Local groups: "LocalGroup1", "ResolvedGroup2" (resolved from empty name) + // 3. Role-derived groups: "testcust-test-domain-project-one-admin", "testcust-test-domain-project-two-member" + expectedGroups := map[string]bool{ + "SSOGroup3": false, // From SSO federation + "LocalGroup1": false, // From both SSO (resolved) and local groups + "ResolvedGroup2": false, // From local groups (resolved from empty name) + "testcust-test-domain-project-one-admin": false, // From user role assignment + "testcust-test-domain-project-two-member": false, // From group role assignment + } + + for _, group := range groups { + if _, exists := expectedGroups[group]; exists { + expectedGroups[group] = true + } + } + + // Verify all expected groups were found + for groupName, found := range expectedGroups { + if !found { + t.Errorf("Expected group %s not found in result: %v", groupName, groups) + } + } + + // Verify no duplicates (LocalGroup1 appears in both SSO and local, should be deduped) + localGroup1Count := 0 + for _, group := range groups { + if group == "LocalGroup1" { + localGroup1Count++ + } + } + if localGroup1Count != 1 { + t.Errorf("Expected LocalGroup1 to appear exactly once (deduped), but found %d times", localGroup1Count) + } +} + +func TestGetGroups_NoSSO_OnlyLocalAndRoles(t *testing.T) { + // Test getGroups for non-SSO user (no OSFederation) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/groups": + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{{ID: "g1", Name: "LocalGroup1"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/roles": + _ = json.NewEncoder(w).Encode(struct { + Roles []role `json:"roles"` + }{Roles: []role{{ID: "r1", Name: "admin"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/projects": + _ = json.NewEncoder(w).Encode(struct { + Projects []project `json:"projects"` + }{Projects: []project{{ID: "p1", Name: "TestProject"}}}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v3/users/") && strings.HasSuffix(r.URL.Path, "/groups"): + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{{ID: "g1", Name: "LocalGroup1"}}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "user.id="): + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{{ + Scope: projectScope{Project: identifierContainer{ID: "p1"}}, + User: identifierContainer{ID: "u1"}, + Role: identifierContainer{ID: "r1"}, + }}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "group.id="): + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{ + Host: ts.URL, + client: ts.Client(), + Logger: newNoopLogger(), + Domain: domainKeystone{Name: "Default"}, + CustomerName: "testcust", + } + ctx := context.Background() + + // Non-SSO user (no OSFederation) + ti := &tokenInfo{User: userKeystone{ID: "u1", Name: "testuser", OSFederation: nil}} + + groups, err := c.getGroups(ctx, "token", ti) + if err != nil { + t.Fatalf("getGroups error: %v", err) + } + + // Expected: local group + role-derived group + expected := []string{"LocalGroup1", "testcust-default-testproject-admin"} + if len(groups) != len(expected) { + t.Fatalf("Expected %d groups, got %d: %v", len(expected), len(groups), groups) + } + + for _, exp := range expected { + found := false + for _, actual := range groups { + if actual == exp { + found = true + break + } + } + if !found { + t.Errorf("Expected group %s not found in %v", exp, groups) + } + } +} + +func TestGetGroups_CustomerNameFallback(t *testing.T) { + // Test getGroups when CustomerName is empty, should use getHostname() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v3/groups": + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/roles": + _ = json.NewEncoder(w).Encode(struct { + Roles []role `json:"roles"` + }{Roles: []role{{ID: "r1", Name: "admin"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v3/projects": + _ = json.NewEncoder(w).Encode(struct { + Projects []project `json:"projects"` + }{Projects: []project{{ID: "p1", Name: "TestProject"}}}) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v3/users/") && strings.HasSuffix(r.URL.Path, "/groups"): + _ = json.NewEncoder(w).Encode(groupsResponse{Groups: []keystoneGroup{}}) + case r.Method == http.MethodGet && strings.Contains(r.URL.RawQuery, "user.id="): + _ = json.NewEncoder(w).Encode(struct { + RoleAssignments []roleAssignment `json:"role_assignments"` + }{RoleAssignments: []roleAssignment{{ + Scope: projectScope{Project: identifierContainer{ID: "p1"}}, + User: identifierContainer{ID: "u1"}, + Role: identifierContainer{ID: "r1"}, + }}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{ + Host: ts.URL, // Use the test server URL directly + client: ts.Client(), + Logger: newNoopLogger(), + Domain: domainKeystone{Name: "Default"}, + CustomerName: "", // Empty, should fallback to getHostname() + } + ctx := context.Background() + + ti := &tokenInfo{User: userKeystone{ID: "u1", Name: "testuser", OSFederation: nil}} + + groups, err := c.getGroups(ctx, "token", ti) + if err != nil { + t.Fatalf("getGroups error: %v", err) + } + + // Should use hostname from ts.URL (e.g., "127" from "127.0.0.1") + if len(groups) == 0 { + t.Fatal("Expected at least one role-derived group") + } + + // Verify the group name contains the hostname prefix + found := false + for _, group := range groups { + if strings.Contains(group, "127") && strings.Contains(group, "default-testproject-admin") { + found = true + break + } + } + if !found { + t.Errorf("Expected group with hostname prefix, got: %v", groups) + } +} + +func TestCheckIfUserExists(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/users/existing") { + _ = json.NewEncoder(w).Encode(userResponse{User: struct { + Name string `json:"name"` + Email string `json:"email"` + ID string `json:"id"` + }{Name: "existing", Email: "existing@example.com", ID: "existing"}}) + } else if strings.HasSuffix(r.URL.Path, "/users/nonexistent") { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := &conn{Host: ts.URL, client: ts.Client(), Logger: newNoopLogger()} + ctx := context.Background() + + // Test existing user + exists, err := c.checkIfUserExists(ctx, "existing", "token") + if err != nil { + t.Fatalf("checkIfUserExists error for existing user: %v", err) + } + if !exists { + t.Error("Expected existing user to exist") + } + + // Test non-existent user + exists, err = c.checkIfUserExists(ctx, "nonexistent", "token") + if err != nil { + t.Fatalf("checkIfUserExists error for non-existent user: %v", err) + } + if exists { + t.Error("Expected non-existent user to not exist") + } +} diff --git a/go.mod b/go.mod index 6ab1400bd8..6c4b1e65da 100644 --- a/go.mod +++ b/go.mod @@ -75,12 +75,10 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect @@ -99,10 +97,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/mod v0.25.0 // indirect - golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -110,5 +106,3 @@ require ( ) replace github.com/dexidp/dex/api/v2 => ./api/v2 - -tool entgo.io/ent/cmd/ent diff --git a/go.sum b/go.sum index c91f5aca6c..dc23e21fce 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -155,8 +153,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -266,8 +262,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=