mirror of
https://github.com/coder/coder.git
synced 2026-06-03 21:18:24 +00:00
579daaff70
Fixes CODAGT-146 Add GitLab support to the gitprovider package for gitsync/chatd PR diff flows. This is a squashed stack of 3 PRs: #25651 - refactor(coderd/externalauth): prepare gitprovider for multi-provider support - Change gitprovider.New to return (Provider, error) - Extract shared helpers (parseRetryAfter, checkRateLimitError, countDiffLines, escapePathPreserveSlashes) from github.go - Update all callers (db2sdk, exp_chats, gitsync) for new signature - Add error logging for provider construction failures - Thread context through provider resolution #25652 - feat(coderd/externalauth/gitprovider): add GitLab provider - Implement full Provider interface: FetchPullRequestStatus, FetchPullRequestDiff, FetchBranchDiff, ResolveBranchPullRequest - Handle nested groups, forks, and self-hosted instances - Rate limit detection on both library and raw HTTP paths - URL parsing/building with NormalizePullRequestURL support - Unit tests covering error paths, URL parsing, state mapping - Document GitLab configuration and known limitations #25653 - test(coderd/externalauth/gitprovider): add GitLab VCR integration tests - FetchPullRequestStatus: 4 fixtures (open, conflicts, merged, closed) - FetchPullRequestDiff: 4 fixtures - FetchBranchDiff: 3 fixtures (open, deleted, fork) - ResolveBranchPullRequest: 3 fixtures - go-vcr cassettes with sanitized GitLab API responses
329 lines
11 KiB
Go
329 lines
11 KiB
Go
package externalauth
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/coder/coder/v2/coderd/promoauth"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|