Preflight Gitea credentials with repository reads
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Implementation plan: neutral forge delivery and credential preflight
|
||||
|
||||
Status: implementation in progress; Stages 1–3 are complete and Stages 4–10
|
||||
Status: implementation in progress; Stages 1–4 are complete and Stages 5–10
|
||||
are pending.
|
||||
|
||||
## Purpose and execution
|
||||
@@ -290,7 +290,7 @@ comparison, page acceptance, and delivery outcomes belong to shared policy.
|
||||
|
||||
## Stage 4: Implement read-only Gitea credential preflight
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
1. Implement backend `Preflight` with local destination/token validation and
|
||||
authenticated repository-scoped reads. Perform no source/tag checks here so
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
@@ -33,6 +34,80 @@ type assetHandle struct {
|
||||
downloadURL string
|
||||
}
|
||||
|
||||
type repositoryResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
func (b *Backend) Preflight(ctx context.Context) error {
|
||||
if strings.TrimSpace(b.destination.Token) == "" {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightMissingCredential, Err: errors.New("Gitea token is required")}
|
||||
}
|
||||
base, apiRoot, err := b.destinationAPI("preflight repository access")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := b.client.httpClient()
|
||||
var resolved repositoryResponse
|
||||
endpoint := apiRoot
|
||||
if err := b.client.getJSON(ctx, client, b.destination.Token, "resolve repository", endpoint, &resolved); err != nil {
|
||||
return preflightFailure(err)
|
||||
}
|
||||
if !resolved.matches(b.destination) {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("repository response does not identify the configured destination")}
|
||||
}
|
||||
|
||||
var authenticated repositoryResponse
|
||||
apiPrefix := strings.TrimRight(base.String(), "/") + "/api/v1"
|
||||
endpoint = apiPrefix + "/repositories/" + strconv.FormatInt(resolved.ID, 10)
|
||||
if err := b.client.getJSON(ctx, client, b.destination.Token, "verify authenticated repository access", endpoint, &authenticated); err != nil {
|
||||
return preflightFailure(err)
|
||||
}
|
||||
if !authenticated.matches(b.destination) || authenticated.ID != resolved.ID {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("authenticated repository response does not match the resolved destination")}
|
||||
}
|
||||
// Gitea's permissions.push describes Git code pushes, not release or asset
|
||||
// writes. That metadata cannot establish a delivery-permission deficiency.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository repositoryResponse) matches(destination Destination) bool {
|
||||
if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(repository.FullName, destination.Owner+"/"+destination.Repository) {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(repository.Owner.Login, destination.Owner)
|
||||
}
|
||||
|
||||
func preflightFailure(err error) error {
|
||||
var backendFailure *Failure
|
||||
if !errors.As(err, &backendFailure) {
|
||||
return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: err}
|
||||
}
|
||||
kind := forge.PreflightUnavailable
|
||||
switch backendFailure.Kind {
|
||||
case FailureUnauthorized:
|
||||
if backendFailure.StatusCode == http.StatusUnauthorized {
|
||||
kind = forge.PreflightInvalidCredential
|
||||
} else {
|
||||
kind = forge.PreflightInaccessibleDestination
|
||||
}
|
||||
case FailureNotFound:
|
||||
kind = forge.PreflightInaccessibleDestination
|
||||
case FailureRejected:
|
||||
if backendFailure.StatusCode >= http.StatusInternalServerError {
|
||||
kind = forge.PreflightUnavailable
|
||||
}
|
||||
}
|
||||
return &forge.PreflightFailure{Kind: kind, Err: neutralFailure(err)}
|
||||
}
|
||||
|
||||
func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
|
||||
|
||||
@@ -114,3 +114,134 @@ func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
||||
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", r.Method)
|
||||
}
|
||||
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
||||
t.Errorf("authorization = %q, want %q", got, want)
|
||||
}
|
||||
requests = append(requests, r.URL.Path)
|
||||
writeRepository := func() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 31, "name": "project", "full_name": "owner/project",
|
||||
"owner": map[string]string{"login": "owner"},
|
||||
"permissions": map[string]bool{"admin": false, "push": false, "pull": true},
|
||||
})
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/project", "/api/v1/repositories/31":
|
||||
writeRepository()
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want both repository reads", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) {
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
||||
defer server.Close()
|
||||
destination := testDestination(server.URL)
|
||||
destination.Token = " \t"
|
||||
|
||||
err := NewBackend(destination, server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential {
|
||||
t.Fatalf("Preflight() error = %v, want missing credential", err)
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("made %d requests without a credential", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) {
|
||||
var requests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, r.URL.Path)
|
||||
if r.URL.Path == "/api/v1/repos/owner/project" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}})
|
||||
return
|
||||
}
|
||||
http.Error(w, "token rejected", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential {
|
||||
t.Fatalf("Preflight() error = %v, want invalid credential", err)
|
||||
}
|
||||
if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" {
|
||||
t.Fatalf("preflight requests = %v, want authenticated repository read", requests)
|
||||
}
|
||||
if strings.Contains(err.Error(), testDestination(server.URL).Token) {
|
||||
t.Fatalf("preflight error exposed the credential: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) {
|
||||
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "repository unavailable", status)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination {
|
||||
t.Fatalf("Preflight() error = %v, want inaccessible destination", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
respond func(http.ResponseWriter)
|
||||
}{
|
||||
{name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }},
|
||||
{name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }},
|
||||
{name: "wrong repository identity", respond: func(w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}})
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) }))
|
||||
defer server.Close()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx)
|
||||
var failure *forge.PreflightFailure
|
||||
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user