Files
kandev/apps/backend/cmd/agentctl/github_credential_test.go
T

282 lines
10 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/kandev/kandev/internal/githubauth"
)
const (
envGitHubCredentialBrokerURL = githubauth.CredentialBrokerURLEnv
envGitHubCredentialLease = githubauth.CredentialLeaseEnv
envGitHubCredentialTaskID = githubauth.CredentialTaskIDEnv
envGitHubCredentialSessionID = githubauth.CredentialSessionIDEnv
envGitHubCredentialRepository = githubauth.CredentialRepositoryEnv
envGitHubCredentialOwner = githubauth.CredentialOwnerEnv
envGitHubCredentialRepo = githubauth.CredentialRepoEnv
envGitHubCredentialHost = githubauth.CredentialHostEnv
envGitHubCredentialScopes = githubauth.CredentialScopesEnv
)
func TestGitHubCredentialHelperGetsFreshCredential(t *testing.T) {
var got githubBrokerResolveRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"username":"x-access-token","password":"fresh-token"}`)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
var stdout bytes.Buffer
err := runGitHubCredentialHelper(
context.Background(),
[]string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=acme/widgets.git\n\n"),
&stdout,
lookupEnv(env),
server.Client(),
)
if err != nil {
t.Fatalf("runGitHubCredentialHelper() error = %v", err)
}
if got.Lease != "opaque-lease" || got.TaskID != "task-1" || got.SessionID != "session-1" {
t.Fatalf("broker scope = %+v", got)
}
if got.RepositoryID != "repo-1" || got.Owner != "acme" || got.Repo != "widgets" || got.Host != "github.com" {
t.Fatalf("broker repository scope = %+v", got)
}
if want := "username=x-access-token\npassword=fresh-token\n\n"; stdout.String() != want {
t.Fatalf("credential helper output = %q, want %q", stdout.String(), want)
}
}
func TestGitHubCredentialHelperRejectsRepositoryScopeMismatch(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
err := runGitHubCredentialHelper(
context.Background(),
[]string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=other/repository.git\n\n"),
io.Discard,
lookupEnv(env),
server.Client(),
)
if err == nil || !strings.Contains(err.Error(), "does not match credential lease scope") {
t.Fatalf("runGitHubCredentialHelper() error = %v, want scope mismatch", err)
}
if requests != 0 {
t.Fatalf("broker requests = %d, want 0", requests)
}
}
func TestGitHubCredentialHelperRejectsRepositoryPathCaseMismatch(t *testing.T) {
env := githubCredentialTestEnv("https://broker.example/resolve")
env[envGitHubCredentialOwner] = "Team"
env[envGitHubCredentialRepo] = "Repo"
err := runGitHubCredentialHelper(
context.Background(),
[]string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=team/repo.git\n\n"),
io.Discard,
lookupEnv(env),
http.DefaultClient,
)
if err == nil || !strings.Contains(err.Error(), "does not match credential lease scope") {
t.Fatalf("runGitHubCredentialHelper() error = %v, want case-sensitive scope mismatch", err)
}
}
func TestGitHubCredentialHelperRequiresCompleteScope(t *testing.T) {
for name, input := range map[string]string{
"protocol": "host=github.com\npath=acme/widgets.git\n\n",
"host": "protocol=https\npath=acme/widgets.git\n\n",
"path": "protocol=https\nhost=github.com\n\n",
} {
t.Run(name, func(t *testing.T) {
env := githubCredentialTestEnv("https://broker.example/resolve")
err := runGitHubCredentialHelper(
context.Background(), []string{"get"}, strings.NewReader(input), io.Discard,
lookupEnv(env), http.DefaultClient,
)
if err == nil {
t.Fatal("runGitHubCredentialHelper() error = nil, want incomplete scope rejection")
}
})
}
}
func TestGitHubCredentialHelperSelectsRepositoryLease(t *testing.T) {
var got githubBrokerResolveRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode request: %v", err)
}
_, _ = io.WriteString(w, `{"username":"x-access-token","password":"fresh-token"}`)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
env[envGitHubCredentialScopes] = `[
{"lease":"frontend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-1","owner":"acme","repo":"frontend","host":"github.com"},
{"lease":"backend-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-2","owner":"acme","repo":"backend","host":"github.com"}
]`
err := runGitHubCredentialHelper(
context.Background(), []string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=acme/backend.git\n\n"),
io.Discard, lookupEnv(env), server.Client(),
)
if err != nil {
t.Fatalf("runGitHubCredentialHelper() error = %v", err)
}
if got.Lease != "backend-lease" || got.RepositoryID != "repo-2" {
t.Fatalf("selected broker scope = %+v", got)
}
}
func TestGitHubCredentialHelperPreservesExactProviderPath(t *testing.T) {
var got githubBrokerResolveRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode request: %v", err)
}
_, _ = io.WriteString(w, `{"username":"x-token-auth","password":"fresh-token"}`)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
env[envGitHubCredentialScopes] = `[
{"lease":"opaque-lease","task_id":"task-1","session_id":"session-1","repository_id":"repo-1","owner":"context","repo":"scm/ENG/widgets","host":"bitbucket.example","path":"/context/scm/ENG/widgets"}
]`
err := runGitHubCredentialHelper(
context.Background(), []string{"get"},
strings.NewReader("protocol=https\nhost=bitbucket.example\npath=context/scm/ENG/widgets\n\n"),
io.Discard, lookupEnv(env), server.Client(),
)
if err != nil {
t.Fatalf("runGitHubCredentialHelper() error = %v", err)
}
if got.Path != "/context/scm/ENG/widgets" {
t.Fatalf("broker path = %q, want exact provider path", got.Path)
}
}
func TestGitHubCredentialHelperIgnoresStoreAndErase(t *testing.T) {
for _, operation := range []string{"store", "erase"} {
t.Run(operation, func(t *testing.T) {
if err := runGitHubCredentialHelper(
context.Background(), []string{operation}, strings.NewReader("password=secret\n\n"),
io.Discard, lookupEnv(nil), http.DefaultClient,
); err != nil {
t.Fatalf("runGitHubCredentialHelper(%q) error = %v", operation, err)
}
})
}
}
func TestGitHubCredentialHelperSurfacesBrokerErrorBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, `{"error":"authentication required"}`)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
err := runGitHubCredentialHelper(
context.Background(), []string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=acme/widgets.git\n\n"),
io.Discard, lookupEnv(env), server.Client(),
)
if err == nil {
t.Fatal("runGitHubCredentialHelper() error = nil, want broker denial")
}
want := `resolve GitHub credential: broker returned HTTP 401: {"error":"authentication required"}`
if err.Error() != want {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
}
func TestGitHubCredentialHelperTruncatesAndSanitizesBrokerErrorBody(t *testing.T) {
rawBody := "prefix\x00\x01\x1f control chars" + strings.Repeat("x", 600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, rawBody)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
err := runGitHubCredentialHelper(
context.Background(), []string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=acme/widgets.git\n\n"),
io.Discard, lookupEnv(env), server.Client(),
)
if err == nil {
t.Fatal("runGitHubCredentialHelper() error = nil, want broker denial")
}
if !strings.HasPrefix(err.Error(), "resolve GitHub credential: broker returned HTTP 500: ") {
t.Fatalf("error = %q, want HTTP 500 prefix", err.Error())
}
appended := strings.TrimPrefix(err.Error(), "resolve GitHub credential: broker returned HTTP 500: ")
if len(appended) > 512 {
t.Fatalf("appended body length = %d, want <= 512", len(appended))
}
for _, r := range appended {
if r < 0x20 {
t.Fatalf("appended body contains control char %q: %q", r, appended)
}
}
}
func TestGitHubCredentialHelperOmitsAppendedBodyWhenEmpty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(server.Close)
env := githubCredentialTestEnv(server.URL)
err := runGitHubCredentialHelper(
context.Background(), []string{"get"},
strings.NewReader("protocol=https\nhost=github.com\npath=acme/widgets.git\n\n"),
io.Discard, lookupEnv(env), server.Client(),
)
want := "resolve GitHub credential: broker returned HTTP 502"
if err == nil || err.Error() != want {
t.Fatalf("error = %v, want %q", err, want)
}
}
func githubCredentialTestEnv(url string) map[string]string {
return map[string]string{
envGitHubCredentialBrokerURL: url,
envGitHubCredentialLease: "opaque-lease",
envGitHubCredentialTaskID: "task-1",
envGitHubCredentialSessionID: "session-1",
envGitHubCredentialRepository: "repo-1",
envGitHubCredentialOwner: "acme",
envGitHubCredentialRepo: "widgets",
envGitHubCredentialHost: "github.com",
}
}
func lookupEnv(env map[string]string) func(string) string {
return func(key string) string { return env[key] }
}