Ctrl K
ComunidadGuías…

coderd/externalauth/externalauth_internal_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package externalauth

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/oauth2"

	"cdr.dev/slog/v3"
	"cdr.dev/slog/v3/sloggers/slogjson"
	"github.com/coder/coder/v2/coderd/promoauth"
	"github.com/coder/coder/v2/codersdk"
)

func TestLogThrottle(t *testing.T) {
	t.Parallel()

	const interval = time.Minute
	var th logThrottle
	start := time.Now()

	suppressed, ok := th.shouldLog(start, interval)
	require.True(t, ok, "the first event should log")
	require.EqualValues(t, 0, suppressed)

	for i := range 3 {
		_, ok := th.shouldLog(start.Add(time.Duration(i+1)*time.Second), interval)
		require.False(t, ok, "events within the interval should be suppressed")
	}
	_, ok = th.shouldLog(start.Add(interval-time.Millisecond), interval)
	require.False(t, ok, "an event just inside the interval should be suppressed")

	suppressed, ok = th.shouldLog(start.Add(interval), interval)
	require.True(t, ok, "the first event after the interval should log")
	require.EqualValues(t, 4, suppressed, "suppressed should count events since the last log")

	suppressed, ok = th.shouldLog(start.Add(2*interval), interval)
	require.True(t, ok)
	require.EqualValues(t, 0, suppressed, "suppressed should reset after each log")

	// Suppress one event, then let more than two intervals elapse.
	_, ok = th.shouldLog(start.Add(2*interval+time.Second), interval)
	require.False(t, ok)
	suppressed, ok = th.shouldLog(start.Add(5*interval), interval)
	require.True(t, ok)
	require.EqualValues(t, 0, suppressed, "counts from a burst that ended more than an interval ago are discarded")
}

func TestLogThrottleConcurrent(t *testing.T) {
	t.Parallel()

	const (
		interval = time.Minute
		events   = 32
	)
	var th logThrottle
	now := time.Now()

	var (
		wg     sync.WaitGroup
		logged atomic.Int64
	)
	for range events {
		wg.Go(func() {
			if _, ok := th.shouldLog(now, interval); ok {
				logged.Add(1)
			}
		})
	}
	wg.Wait()
	require.EqualValues(t, 1, logged.Load(), "exactly one concurrent event should log")

	suppressed, ok := th.shouldLog(now.Add(interval), interval)
	require.True(t, ok)
	require.EqualValues(t, events-1, suppressed, "every other concurrent event should be counted")
}

// TestLogRateLimitedValidationSuppressed verifies the suppressed count
// reaches the emitted log line.
func TestLogRateLimitedValidationSuppressed(t *testing.T) {
	t.Parallel()

	logs := &bytes.Buffer{}
	c := &Config{Logger: slog.Make(slogjson.Sink(logs)).Leveled(slog.LevelDebug)}
	c.rateLimitLogThrottle.lastLog = time.Now().Add(-rateLimitLogInterval - time.Second)
	c.rateLimitLogThrottle.suppressed = 5

	c.logRateLimitedValidation(context.Background(), http.StatusTooManyRequests, "status_code")

	var entry struct {
		Fields struct {
			Suppressed *int64 `json:"suppressed"`
		} `json:"fields"`
	}
	require.NoError(t, json.Unmarshal(logs.Bytes(), &entry))
	require.NotNil(t, entry.Fields.Suppressed, "the log line should carry the suppressed field")
	require.EqualValues(t, 5, *entry.Fields.Suppressed)
}

func TestGitlabDefaults(t *testing.T) {
	t.Parallel()

	// The default cloud setup. Copying this here as hard coded
	// values.
	cloud := func() codersdk.ExternalAuthConfig {
		return codersdk.ExternalAuthConfig{
			Type:                          string(codersdk.EnhancedExternalAuthProviderGitLab),
			ID:                            string(codersdk.EnhancedExternalAuthProviderGitLab),
			AuthURL:                       "https://gitlab.com/oauth/authorize",
			TokenURL:                      "https://gitlab.com/oauth/token",
			ValidateURL:                   "https://gitlab.com/oauth/token/info",
			RevokeURL:                     "https://gitlab.com/oauth/revoke",
			DisplayName:                   "GitLab",
			DisplayIcon:                   "/icon/gitlab.svg",
			Regex:                         `^(https?://)?gitlab\.com(/.*)?$`,
			APIBaseURL:                    "https://gitlab.com/api/v4",
			Scopes:                        []string{"write_repository", "read_api"},
			CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
		}
	}

	tests := []struct {
		name           string
		input          codersdk.ExternalAuthConfig
		expected       codersdk.ExternalAuthConfig
		mutateExpected func(*codersdk.ExternalAuthConfig)
	}{
		// Cloud
		{
			name: "OnlyType",
			input: codersdk.ExternalAuthConfig{
				Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
			},
			expected: cloud(),
		},
		{
			// If someone was to manually configure the gitlab cli.
			name: "CloudByConfig",
			input: codersdk.ExternalAuthConfig{
				Type:    string(codersdk.EnhancedExternalAuthProviderGitLab),
				AuthURL: "https://gitlab.com/oauth/authorize",
			},
			expected: cloud(),
		},
		{
			// Changing some of the defaults of the cloud option
			name: "CloudWithChanges",
			input: codersdk.ExternalAuthConfig{
				Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
				// Adding an extra query param intentionally to break simple
				// string comparisons.
				AuthURL:     "https://gitlab.com/oauth/authorize?foo=bar",
				DisplayName: "custom",
				Regex:       ".*",
			},
			expected: cloud(),
			mutateExpected: func(config *codersdk.ExternalAuthConfig) {
				config.AuthURL = "https://gitlab.com/oauth/authorize?foo=bar"
				config.DisplayName = "custom"
				config.Regex = ".*"
			},
		},
		// Self-hosted
		{
			// Dynamically figures out the Validate, Token, and Regex fields.
			name: "SelfHostedOnlyAuthURL",
			input: codersdk.ExternalAuthConfig{
				Type:    string(codersdk.EnhancedExternalAuthProviderGitLab),
				AuthURL: "https://gitlab.company.org/oauth/authorize?foo=bar",
			},
			expected: cloud(),
			mutateExpected: func(config *codersdk.ExternalAuthConfig) {
				config.AuthURL = "https://gitlab.company.org/oauth/authorize?foo=bar"
				config.ValidateURL = "https://gitlab.company.org/oauth/token/info"
				config.TokenURL = "https://gitlab.company.org/oauth/token"
				config.RevokeURL = "https://gitlab.company.org/oauth/revoke"
				config.Regex = `^(https?://)?gitlab\.company\.org(/.*)?$`
				config.APIBaseURL = "https://gitlab.company.org/api/v4"
			},
		},
		{
			// Strange values
			name: "RandomValues",
			input: codersdk.ExternalAuthConfig{
				Type:                          string(codersdk.EnhancedExternalAuthProviderGitLab),
				AuthURL:                       "https://auth.com/auth",
				ValidateURL:                   "https://validate.com/validate",
				TokenURL:                      "https://token.com/token",
				RevokeURL:                     "https://token.com/revoke",
				Regex:                         "random",
				CodeChallengeMethodsSupported: []string{"random"},
			},
			expected: cloud(),
			mutateExpected: func(config *codersdk.ExternalAuthConfig) {
				config.AuthURL = "https://auth.com/auth"
				config.ValidateURL = "https://validate.com/validate"
				config.TokenURL = "https://token.com/token"
				config.RevokeURL = "https://token.com/revoke"
				config.Regex = `random`
				config.CodeChallengeMethodsSupported = []string{"random"}
				config.APIBaseURL = "https://auth.com/api/v4"
			},
		},
	}
	for _, c := range tests {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			applyDefaultsToConfig(&c.input)
			if c.mutateExpected != nil {
				c.mutateExpected(&c.expected)
			}
			require.Equal(t, c.input, c.expected)
		})
	}
}

func TestIsFailedRefresh(t *testing.T) {
	t.Parallel()

	expiredToken := &oauth2.Token{
		RefreshToken: "refresh-token",
		// isFailedRefresh returns early at the existingToken.Valid()
		// guard if the token is valid. Valid() requires
		// AccessToken != "" AND not expired. This fixture has no
		// AccessToken so Valid() is always false, but we set an
		// expired time as a safety net in case someone later adds
		// an AccessToken field.
		Expiry: time.Now().Add(-time.Hour),
	}

	tests := []struct {
		name     string
		err      error
		expected bool
	}{
		{
			name: "IncorrectClientCredentials_StatusOK",
			err: &oauth2.RetrieveError{
				Response:  &http.Response{StatusCode: http.StatusOK},
				ErrorCode: "incorrect_client_credentials",
			},
			// StatusOK fallthrough also returns true, so this test
			// documents the combined behavior. See the 403-status
			// variant below for error-code-only isolation.
			expected: true,
		},
		{
			// Uses 403 status (excluded from the status code switch)
			// so the only path to true is the error code switch.
			name: "IncorrectClientCredentials_Status403",
			err: &oauth2.RetrieveError{
				Response:  &http.Response{StatusCode: http.StatusForbidden},
				ErrorCode: "incorrect_client_credentials",
			},
			expected: true,
		},
		{
			name: "InvalidClient_Status401",
			err: &oauth2.RetrieveError{
				Response:  &http.Response{StatusCode: http.StatusUnauthorized},
				ErrorCode: "invalid_client",
			},
			// StatusUnauthorized fallthrough also returns true, so
			// this test documents the combined behavior.
			expected: true,
		},
		{
			// Uses 403 status (excluded from the status code switch)
			// so the only path to true is the error code switch.
			name: "InvalidClient_Status403",
			err: &oauth2.RetrieveError{
				Response:  &http.Response{StatusCode: http.StatusForbidden},
				ErrorCode: "invalid_client",
			},
			expected: true,
		},
		{
			name: "UnknownErrorCode_Status403_Transient",
			err: &oauth2.RetrieveError{
				Response:  &http.Response{StatusCode: http.StatusForbidden},
				ErrorCode: "unknown_code",
			},
			// 403 with unknown error code should be transient (safe
			// default: retry rather than destroy the token).
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := isFailedRefresh(expiredToken, tt.err)
			assert.Equal(t, tt.expected, got)
		})
	}
}

func Test_bitbucketServerConfigDefaults(t *testing.T) {
	t.Parallel()

	bbType := string(codersdk.EnhancedExternalAuthProviderBitBucketServer)
	tests := []struct {
		name     string
		config   *codersdk.ExternalAuthConfig
		expected codersdk.ExternalAuthConfig
	}{
		{
			// Very few fields are statically defined for Bitbucket Server.
			name: "EmptyBitbucketServer",
			config: &codersdk.ExternalAuthConfig{
				Type: bbType,
			},
			expected: codersdk.ExternalAuthConfig{
				Type:                          bbType,
				ID:                            bbType,
				DisplayName:                   "Bitbucket Server",
				Scopes:                        []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
				DisplayIcon:                   "/icon/bitbucket.svg",
				CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
			},
		},
		{
			// Only the AuthURL is required for defaults to work.
			name: "AuthURL",
			config: &codersdk.ExternalAuthConfig{
				Type:    bbType,
				AuthURL: "https://bitbucket.example.com/login/oauth/authorize",
			},
			expected: codersdk.ExternalAuthConfig{
				Type:                          bbType,
				ID:                            bbType,
				AuthURL:                       "https://bitbucket.example.com/login/oauth/authorize",
				TokenURL:                      "https://bitbucket.example.com/rest/oauth2/latest/token",
				ValidateURL:                   "https://bitbucket.example.com/rest/api/latest/inbox/pull-requests/count",
				Scopes:                        []string{"PUBLIC_REPOS", "REPO_READ", "REPO_WRITE"},
				Regex:                         `^(https?://)?bitbucket\.example\.com(/.*)?$`,
				DisplayName:                   "Bitbucket Server",
				DisplayIcon:                   "/icon/bitbucket.svg",
				CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
			},
		},
		{
			// Ensure backwards compatibility. The type should update to "bitbucket-cloud",
			// but the ID and other fields should remain the same.
			name: "BitbucketLegacy",
			config: &codersdk.ExternalAuthConfig{
				Type: "bitbucket",
			},
			expected: codersdk.ExternalAuthConfig{
				Type:                          string(codersdk.EnhancedExternalAuthProviderBitBucketCloud),
				ID:                            "bitbucket", // Legacy ID remains unchanged
				AuthURL:                       "https://bitbucket.org/site/oauth2/authorize",
				TokenURL:                      "https://bitbucket.org/site/oauth2/access_token",
				ValidateURL:                   "https://api.bitbucket.org/2.0/user",
				DisplayName:                   "BitBucket",
				DisplayIcon:                   "/icon/bitbucket.svg",
				Regex:                         `^(https?://)?bitbucket\.org(/.*)?$`,
				Scopes:                        []string{"account", "repository:write"},
				CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodNone)},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			applyDefaultsToConfig(tt.config)
			require.Equal(t, tt.expected, *tt.config)
		})
	}
}

func TestUntyped(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		input    codersdk.ExternalAuthConfig
		expected codersdk.ExternalAuthConfig
	}{
		{
			// Unknown Type uses S256 by default.
			name: "RandomValues",
			input: codersdk.ExternalAuthConfig{
				Type:        "unknown",
				AuthURL:     "https://auth.com/auth",
				ValidateURL: "https://validate.com/validate",
				TokenURL:    "https://token.com/token",
				RevokeURL:   "https://token.com/revoke",
				Regex:       "random",
			},
			expected: codersdk.ExternalAuthConfig{
				ID:                            "unknown",
				Type:                          "unknown",
				DisplayName:                   "unknown",
				DisplayIcon:                   "/emojis/1f511.png",
				AuthURL:                       "https://auth.com/auth",
				ValidateURL:                   "https://validate.com/validate",
				TokenURL:                      "https://token.com/token",
				RevokeURL:                     "https://token.com/revoke",
				Regex:                         `random`,
				CodeChallengeMethodsSupported: []string{string(promoauth.PKCEChallengeMethodSha256)},
			},
		},
	}
	for _, c := range tests {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			applyDefaultsToConfig(&c.input)
			require.Equal(t, c.input, c.expected)
		})
	}
}