mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 04:04:15 +00:00
server.test_mode survived the build-tag refactor only to feed five behavioral branches: the registry's fall-back to the default hold when the user's hold is unreachable, backfill warning suppression for external holds, the appview listener close on shutdown, the hold's relay-crawl skip, and the hold's appview-issuer tolerance. Every one of them is a "this is a local development build" decision, which is what the tag already says, and local development has to build with the tag or nothing resolves. So they read atproto.TestModeBuild now, and the flag, SetTestMode, IsTestMode, the middleware option, the backfill constructor parameter, the never-read field on RemoteHoldAuthorizer, the example and template YAML lines, and the docker-compose env vars are gone. The registry keeps the fallback as a field seeded from the constant so the production-path tests can pin it off under the tag. The 24 SetTestMode calls in tests were dead already: stripping them and running the affected packages tagged changed nothing. Tests that resolve a loopback did:web used to t.Fatal naming the tag, which left a bare `go test ./...` permanently red in five packages. They now live under `//go:build testmode`: whole-file constraints where every test needs it, and sibling *_testmode_test.go files holding the moved tests plus their fixtures where a file mixed. The harness carries the constraint too, with its package doc in an untagged doc.go so the package still exists without it. An untagged run compiles those tests out and passes; make test keeps the tag and runs everything. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
151 lines
5.3 KiB
Go
151 lines
5.3 KiB
Go
//go:build testmode
|
|
|
|
package holdclient
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/testpds"
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
)
|
|
|
|
// holdServer starts an httptest server standing in for a hold and returns it
|
|
// with the did:web that ResolveHoldDIDToURL maps back to it. The server serves
|
|
// its own DID document at /.well-known/did.json (always promptly, whatever the
|
|
// hold handler does), and handler gets every other request. Resolving a
|
|
// loopback did:web is what a `-tags testmode` build provides, which this
|
|
// file's build constraint guarantees.
|
|
func holdServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, string) {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
|
|
base := "http://" + r.Host
|
|
testpds.HoldDIDDocumentHandler(testpds.DIDWebForURL(base), base)(w, r)
|
|
})
|
|
mux.Handle("/", handler)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv, testpds.DIDWebForURL(srv.URL)
|
|
}
|
|
|
|
func testKey(t *testing.T) *atcrypto.PrivateKeyP256 {
|
|
t.Helper()
|
|
priv, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
t.Fatalf("generate key: %v", err)
|
|
}
|
|
return priv
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_JoinedErrorNamesEveryFailingHold: the caller
|
|
// 5xxs the Stripe webhook on any non-nil return, and the operator's only
|
|
// account of which holds are behind is this error. One failing hold must not
|
|
// mask another.
|
|
func TestUpdateCrewTierOnAllHolds_JoinedErrorNamesEveryFailingHold(t *testing.T) {
|
|
_, okDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
_, bad1DID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "down", http.StatusServiceUnavailable)
|
|
})
|
|
_, bad2DID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "broken", http.StatusInternalServerError)
|
|
})
|
|
|
|
err := UpdateCrewTierOnAllHolds(context.Background(),
|
|
[]string{okDID, bad1DID, bad2DID}, "did:plc:user", 1, testKey(t), "did:web:appview")
|
|
if err == nil {
|
|
t.Fatal("expected an error when two of three holds fail")
|
|
}
|
|
for _, did := range []string{bad1DID, bad2DID} {
|
|
if !strings.Contains(err.Error(), did) {
|
|
t.Errorf("joined error does not name failing hold %s: %v", did, err)
|
|
}
|
|
}
|
|
if strings.Contains(err.Error(), okDID) {
|
|
t.Errorf("joined error names the hold that succeeded (%s): %v", okDID, err)
|
|
}
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_SlowHoldDoesNotStarveOthers pins the concurrency
|
|
// the function's doc claims.
|
|
//
|
|
// Contacted serially, one hold that burns the whole deadline means the holds
|
|
// after it are never contacted at all — and since the webhook retries in the
|
|
// same order, a persistently slow first hold would mean later holds are never
|
|
// updated on any delivery. The assertion is that the healthy hold is reached
|
|
// even though the slow one is listed first and never answers.
|
|
func TestUpdateCrewTierOnAllHolds_SlowHoldDoesNotStarveOthers(t *testing.T) {
|
|
release := make(chan struct{})
|
|
_, slowDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
<-release
|
|
})
|
|
defer close(release)
|
|
|
|
var healthyHits atomic.Int32
|
|
_, healthyDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
healthyHits.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
// Slow hold first: serial contact would spend the entire budget on it.
|
|
err := UpdateCrewTierOnAllHolds(ctx,
|
|
[]string{slowDID, healthyDID},
|
|
"did:plc:user", 1, testKey(t), "did:web:appview")
|
|
|
|
if err == nil {
|
|
t.Error("expected an error naming the slow hold")
|
|
}
|
|
if got := healthyHits.Load(); got != 1 {
|
|
t.Errorf("healthy hold contacted %d times, want 1 — it was starved by the slow hold", got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateCrewTierOnAllHolds_DeadlineCutsRetriesShort documents a real
|
|
// mismatch rather than asserting an intent.
|
|
//
|
|
// tierUpdateMaxAttempts is 3 and each attempt is bounded by a 5s client
|
|
// timeout, so three attempts against a hold that accepts and never answers
|
|
// need ~15s. The Stripe webhook allows the whole fan-out 10s. Under a hang the
|
|
// budget therefore funds two attempts, never three, and the caller gets the
|
|
// context error rather than the "after N attempts" wrapper. If the deadline or
|
|
// either constant changes, this test is where the arithmetic gets re-checked.
|
|
func TestUpdateCrewTierOnAllHolds_DeadlineCutsRetriesShort(t *testing.T) {
|
|
release := make(chan struct{})
|
|
var attempts atomic.Int32
|
|
_, hungDID := holdServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
attempts.Add(1)
|
|
<-release
|
|
})
|
|
defer close(release)
|
|
|
|
// Deadline deliberately shorter than tierUpdateMaxAttempts would need.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
err := UpdateCrewTierOnAllHolds(ctx, []string{hungDID},
|
|
"did:plc:user", 1, testKey(t), "did:web:appview")
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error from a hold that never answers")
|
|
}
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("fan-out took %v; the context deadline did not abort the retry loop", elapsed)
|
|
}
|
|
if got := attempts.Load(); got >= int32(tierUpdateMaxAttempts) {
|
|
t.Errorf("hung hold was attempted %d times under a deadline that cannot fund %d",
|
|
got, tierUpdateMaxAttempts)
|
|
}
|
|
}
|