maidn-cli/internal/forgejo/repo_test.go

512 lines
21 KiB
Go

package forgejo
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/registry-user/tokens" {
t.Fatal("unexpected Forgejo token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "registry-user" || password != "password" {
t.Fatal("Forgejo token request did not use the supplied basic authentication")
}
if request.Header.Get("X-Forgejo-OTP") != "123456" || request.Header.Get("Content-Type") != "application/json" {
t.Fatal("Forgejo token request headers are incorrect")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-registry" || !reflect.DeepEqual(body.Scopes, []string{"read:package", "write:package"}) {
t.Fatal("Forgejo token request did not use the least-privilege package scopes")
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"registry-token"}`))
}))
defer server.Close()
token, err := createRegistryToken(server.Client(), server.URL, "registry-user", "password", "123456", "maidn-registry")
if err != nil || token != "registry-token" {
t.Fatal("CreateRegistryToken() did not return the Forgejo token")
}
}
func TestCreateDeliveryStatusTokenUsesOnlyStatusAndCommentScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/delivery-bot/tokens" {
t.Fatal("unexpected Forgejo delivery-token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "delivery-bot" || password != "password" || request.Header.Get("X-Forgejo-OTP") != "123456" {
t.Fatal("delivery token request did not use the supplied credentials through HTTP authentication")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-delivery-status" || !reflect.DeepEqual(body.Scopes, []string{"write:issue", "write:repository"}) {
t.Fatalf("delivery token request has unexpected privileges: %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"delivery-token"}`))
}))
defer server.Close()
token, err := createDeliveryStatusToken(server.Client(), server.URL, "delivery-bot", "password", "123456")
if err != nil || token != "delivery-token" {
t.Fatal("CreateDeliveryStatusToken() did not return the Forgejo token")
}
}
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
original := copyGit
t.Cleanup(func() { copyGit = original })
var commands []string
copyGit = func(_ string, _ []string, args ...string) error {
commands = append(commands, strings.Join(args, " "))
return nil
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/catalog" {
t.Fatalf("unexpected lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
if request.URL.Path != "/api/v1/orgs/owner/repos" {
t.Fatalf("unexpected create %q", request.URL.Path)
}
var body createRepoRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.AutoInit || body.DefaultBranch != "release" {
t.Fatalf("unexpected catalog create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
manager.HTTPClient = server.Client()
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); err != nil || !created {
t.Fatalf("EnsureRepositoryCopy() = (%t, %v)", created, err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "clone --mirror https://catalog.example.test/tekton.git") || !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") || strings.Contains(joined, "test-token") {
t.Fatalf("catalog copy command boundary is unsafe: %q", joined)
}
}
func TestEnsureRepositoryCopyRejectsMissingConfiguredRefAfterCopy(t *testing.T) {
original := copyGit
t.Cleanup(func() { copyGit = original })
var commands []string
copyGit = func(_ string, _ []string, args ...string) error {
commands = append(commands, strings.Join(args, " "))
if args[0] == "ls-remote" {
return errors.New("missing ref")
}
return nil
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "release", "")
manager.HTTPClient = server.Client()
if created, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://catalog.example.test/tekton.git"); !created || err == nil || !strings.Contains(err.Error(), "configured catalog ref") {
t.Fatalf("EnsureRepositoryCopy() = (%t, %v), want copied catalog ref error", created, err)
}
joined := strings.Join(commands, "\n")
if !strings.Contains(joined, "push --mirror "+CloneURL(server.URL, "owner", "catalog")) || !strings.Contains(joined, "ls-remote --exit-code "+CloneURL(server.URL, "owner", "catalog")+" refs/heads/release") {
t.Fatalf("catalog copy did not verify the missing non-default ref: %q", joined)
}
}
func TestEnsureRepositoryCopyRejectsCredentialBearingSourceBeforeHTTP(t *testing.T) {
manager := NewRepoManager("https://git.example.test", "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("credential-bearing source reached Forgejo HTTP")
return nil, nil
})}
if _, err := manager.EnsureRepositoryCopy("catalog", "catalog", "https://reader:secret@catalog.example.test/tekton.git"); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("EnsureRepositoryCopy() error = %v", err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if _, err := manager.repoExists("cluster"); err == nil {
t.Fatal("repoExists() accepted an unauthorized response")
}
}
func TestEnsureOrganizationCreatesOnlyWhenRequested(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/orgs/new-org" {
t.Fatalf("unexpected organization lookup %q", request.URL.Path)
}
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
var body createOrganizationRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Username != "new-org" {
t.Fatalf("unexpected organization create request: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected Forgejo method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "new-org", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if _, err := manager.EnsureOrganization(false); err == nil {
t.Fatal("missing organization was accepted without explicit create")
}
if requests != 1 {
t.Fatal("organization lookup performed unexpected remote actions")
}
if created, err := manager.EnsureOrganization(true); err != nil || !created {
t.Fatalf("EnsureOrganization(true) = (%t, %v)", created, err)
}
}
func TestEnsureWebhookUpdatesMatchingURL(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/hooks" {
t.Fatalf("unexpected lookup path %q", request.URL.Path)
}
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, Config: map[string]string{"url": "https://tekton.example.test/"}}})
case http.MethodPatch:
if request.URL.Path != "/api/v1/repos/owner/app/hooks/7" {
t.Fatalf("unexpected update path %q", request.URL.Path)
}
var body hookRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Type != "" || body.AuthorizationHeader != "Bearer token" || len(body.Events) != 2 {
t.Fatalf("unexpected hook request: %#v", body)
}
writer.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil {
t.Fatal(err)
}
if requests != 2 {
t.Fatalf("requests = %d, want 2", requests)
}
}
func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]hook{})
case http.MethodPost:
var body hookRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Type != "forgejo" || body.Config["content_type"] != "json" {
t.Fatalf("unexpected hook request: %#v", body)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.EnsureWebhook("app", "https://tekton.example.test/", "Bearer token"); err != nil {
t.Fatal(err)
}
}
func TestTriggerWebhookTestUsesManagedHookAndBaseBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method + " " + request.URL.Path {
case http.MethodGet + " /api/v1/repos/owner/app/hooks":
_ = json.NewEncoder(writer).Encode([]hook{{ID: 7, URL: "https://tekton.example.test/"}})
case http.MethodPost + " /api/v1/repos/owner/app/hooks/7/tests":
if request.URL.Query().Get("ref") != "main" || request.Header.Get("Authorization") == "" {
t.Fatal("webhook test did not use the managed hook and base branch")
}
writer.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Forgejo request %s %s", request.Method, request.URL.String())
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.TriggerWebhookTest("app", "https://tekton.example.test/", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchCreatesDirectPushProtection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/owner/app/branch_protections" {
t.Fatalf("unexpected branch protection path %q", request.URL.Path)
}
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]branchProtection{})
case http.MethodPost:
var body branchProtectionRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.BranchName != "production" || body.EnablePush || body.EnablePushWhitelist {
t.Fatalf("unexpected branch protection request: %#v", body)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("app", "production"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchUsesCanonicalSourceOwnerPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/api/v1/repos/Maidn/maidn-e2e-web/branch_protections" {
t.Fatalf("canonical source mutation targeted %q", request.URL.Path)
}
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]branchProtection{})
case http.MethodPost:
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "Maidn", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("maidn-e2e-web", "production"); err != nil {
t.Fatal(err)
}
}
func TestEnsureProtectedBranchRejectsExistingDirectPushRule(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
t.Fatal("existing unsafe protection must not be changed")
}
_ = json.NewEncoder(writer).Encode([]branchProtection{{ID: 1, BranchName: "production", EnablePush: true}})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsureProtectedBranch("app", "production"); err == nil || !strings.Contains(err.Error(), "permits direct pushes") {
t.Fatalf("EnsureProtectedBranch() error = %v, want unsafe rule error", err)
}
}
func TestRepositoryFromURL(t *testing.T) {
owner, repo, err := RepositoryFromURL("https://git.example.test/team/app.git")
if err != nil || owner != "team" || repo != "app" {
t.Fatalf("RepositoryFromURL() = (%q, %q, %v)", owner, repo, err)
}
}
func TestEnsureRepositoryCreatesWithoutInitialization(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
writer.WriteHeader(http.StatusNotFound)
case http.MethodPost:
var body createRepoRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.AutoInit {
t.Fatal("application repository must be created without an initial commit")
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
created, err := manager.EnsureRepository("app", "description")
if err != nil || !created {
t.Fatalf("EnsureRepository() = (%t, %v)", created, err)
}
}
func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if _, err := manager.HasRemoteBranch(server.URL, "main"); err == nil {
t.Fatal("HasRemoteBranch() accepted a failed git remote lookup")
}
}
func TestPublishRepositoryPullRequestRejectsExistingBranchWithoutOpenPullRequest(t *testing.T) {
original := hasRemoteBranch
t.Cleanup(func() { hasRemoteBranch = original })
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected pull request lookup: %s %s", request.Method, request.URL.String())
}
_ = json.NewEncoder(writer).Encode([]pullRequest{})
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "user", "", "", "main", "")
manager.HTTPClient = server.Client()
hasRemoteBranch = func(got *RepoManager, repoURL, branch string) (bool, error) {
if got != manager || repoURL != CloneURL(server.URL, "owner", "cluster") || branch != "maidn/register-web-ui-deadbeefcafe" {
t.Fatalf("unexpected remote branch lookup: %q %q", repoURL, branch)
}
return true, nil
}
_, err := manager.PublishRepositoryPullRequest("cluster", "register web-ui", "maidn/register-web-ui-deadbeefcafe", "main", func(string) error {
t.Fatal("change ran for a stale registration branch")
return nil
})
if err == nil || !strings.Contains(err.Error(), "without exactly one open pull request") {
t.Fatalf("PublishRepositoryPullRequest() error = %v", err)
}
}
func TestMergePullRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
_ = json.NewEncoder(writer).Encode([]pullRequest{{Number: 4}})
case http.MethodPost:
var body mergePullRequestRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Do != "merge" || request.URL.Path != "/api/v1/repos/owner/cluster/pulls/4/merge" {
t.Fatalf("unexpected merge request")
}
writer.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected method %q", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.MergePullRequest("cluster", "maidn/bootstrap-test"); err != nil {
t.Fatal(err)
}
}
func TestCreatePullRequestAcceptsExistingConflict(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/repos/owner/cluster/pulls" {
t.Fatalf("unexpected pull request request: %s %s", request.Method, request.URL.Path)
}
writer.WriteHeader(http.StatusConflict)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
if err := manager.CreatePullRequest("cluster", "title", "maidn/bootstrap-test", "main"); err != nil {
t.Fatal(err)
}
}
func TestEnsurePullRequestChecksExactOpenBranchBeforeCreating(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests++
switch request.Method {
case http.MethodGet:
if request.URL.Path != "/api/v1/repos/owner/app/pulls" || request.URL.Query().Get("state") != "open" || request.URL.Query().Get("head") != "maidn/delivery-app" {
t.Fatalf("unexpected pull request lookup: %s", request.URL.String())
}
_, _ = writer.Write([]byte("[]"))
case http.MethodPost:
var body pullRequestRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Head != "maidn/delivery-app" || body.Base != "main" {
t.Fatalf("unexpected pull request create: %#v, %v", body, err)
}
writer.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected request method %s", request.Method)
}
}))
defer server.Close()
manager := NewRepoManager(server.URL, "test-token", "owner", "bot", "", "", "main", "")
manager.HTTPClient = server.Client()
if err := manager.EnsurePullRequest("app", "delivery", "maidn/delivery-app", "main"); err != nil || requests != 2 {
t.Fatalf("EnsurePullRequest() = %v, requests = %d", err, requests)
}
}
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test")
manager.HTTPClient = server.Client()
exists, err := manager.repoExists("cluster")
if err != nil || exists {
t.Fatalf("repoExists() = (%t, %v), want (false, nil)", exists, err)
}
}