mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
remove the runtime test_mode switch; the testmode build tag is the only one
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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d8643ee03a
commit
0080957a21
@@ -91,11 +91,14 @@ client plain HTTP clients so it can reach a PDS on loopback.
|
||||
the tag (`make test`, `TEST_TAGS` in the Makefile, `GO_TAGS` in Air/Dockerfile.dev).
|
||||
- Production Dockerfiles and `make build-trixie` never set it. There is no
|
||||
runtime switch: a production binary cannot be configured to resolve local DIDs.
|
||||
- Tests that need loopback did:web resolution call `t.Fatal` naming the tag
|
||||
when `atproto.TestModeBuild` is false, so an untagged `go test` says why.
|
||||
- `server.test_mode` (runtime) still gates the remaining behavioral branches
|
||||
(hold issuer-mismatch tolerance, backfill warning suppression, registry
|
||||
fall-back-to-default-hold); it no longer affects DID resolution.
|
||||
- Tests that need loopback did:web resolution live in `*_testmode_test.go`
|
||||
files (or whole files) under `//go:build testmode`, so a bare `go test ./...`
|
||||
compiles them out and stays green while `make test` runs everything. The
|
||||
integration harness carries the same constraint.
|
||||
- The remaining local-dev behaviors (hold issuer-mismatch tolerance, skipped
|
||||
relay crawl requests, backfill warning suppression, registry
|
||||
fall-back-to-default-hold) read `atproto.TestModeBuild` too. There is no
|
||||
runtime test-mode config key any more.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ stripe-integration-test: ## Run Stripe sandbox-backed billing tests (needs STRIP
|
||||
@echo " STRIPE_TEST_PRICE_MONTHLY, STRIPE_TEST_PRICE_YEARLY"
|
||||
@echo " Optional env: STRIPE_TEST_TIER_NAME (default 'Supporter'),"
|
||||
@echo " STRIPE_TEST_EXISTING_CUSTOMER_DID (skips portal search-lag wait)"
|
||||
go test -tags="billing stripe_integration" -count=1 -timeout=180s ./test/stripe-integration/...
|
||||
go test -tags="billing stripe_integration $(TEST_TAGS)" -count=1 -timeout=180s ./test/stripe-integration/...
|
||||
|
||||
##@ Quality Targets
|
||||
|
||||
@@ -210,7 +210,7 @@ lint: check-golangci-lint ## Run golangci-lint
|
||||
@echo "→ Running golangci-lint (billing tag)..."
|
||||
golangci-lint run --build-tags=billing ./pkg/billing/...
|
||||
@echo "→ Running golangci-lint (testmode tag)..."
|
||||
golangci-lint run --build-tags=testmode ./pkg/atproto/...
|
||||
golangci-lint run --build-tags=testmode ./...
|
||||
|
||||
lex-lint: ## Lint ATProto lexicon schemas
|
||||
goat lex lint ./lexicons/
|
||||
|
||||
@@ -25,8 +25,6 @@ server:
|
||||
addr: :5000
|
||||
# Public-facing URL for OAuth callbacks and JWT realm. Auto-detected if empty.
|
||||
base_url: ""
|
||||
# Local development only. Routes pushes to the default hold when the user's chosen hold is unreachable and quiets backfill warnings about external holds. Does not affect DID resolution: that needs a -tags testmode build.
|
||||
test_mode: false
|
||||
# Display name shown on OAuth authorization screens.
|
||||
client_name: AT Container Registry
|
||||
# Short name used in page titles and browser tabs.
|
||||
|
||||
@@ -43,8 +43,6 @@ server:
|
||||
public: false
|
||||
# DID of successor hold for migration. Appview redirects all requests to the successor.
|
||||
successor: ""
|
||||
# Local development only. Skips relay crawl requests (a local hold is not reachable by public relays) and tolerates an appview token issuer that differs from appview_did. Does not affect DID resolution: that needs a -tags testmode build.
|
||||
test_mode: false
|
||||
# Endpoints used for proactive scan discovery. MUST support com.atproto.sync.listReposByCollection. Also sent requestCrawl on startup (best-effort, in addition to built-in known relays).
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
|
||||
@@ -11,7 +11,6 @@ server:
|
||||
addr: :5000
|
||||
base_url: "https://seamark.dev"
|
||||
client_name: Seamark
|
||||
test_mode: false
|
||||
client_short_name: Seamark
|
||||
registry_domains:
|
||||
- "buoy.cr"
|
||||
|
||||
@@ -19,7 +19,6 @@ server:
|
||||
public_url: "https://{{.HoldDomain}}"
|
||||
public: false
|
||||
successor: ""
|
||||
test_mode: false
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
- https://relay1.us-west.bsky.network
|
||||
|
||||
@@ -32,7 +32,6 @@ services:
|
||||
# Labeler URL (HTTP for dev — ParseLabelerURL accepts it directly so we don't
|
||||
# have to round-trip through did:web → https:// resolution).
|
||||
ATCR_LABELER_DID: did:web:172.28.0.4%3A5002
|
||||
ATCR_SERVER_TEST_MODE: true
|
||||
ATCR_LOG_LEVEL: debug
|
||||
LOG_SHIPPER_BACKEND: victoria
|
||||
LOG_SHIPPER_URL: http://172.28.0.10:9428
|
||||
@@ -76,7 +75,6 @@ services:
|
||||
HOLD_SERVER_PUBLIC_URL: http://localhost:8080
|
||||
HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
|
||||
HOLD_REGISTRATION_ALLOW_ALL_CREW: true
|
||||
HOLD_SERVER_TEST_MODE: true
|
||||
HOLD_LOG_LEVEL: debug
|
||||
# Subscribe to the dev labeler so takedowns purge records on this hold and
|
||||
# GC honors the reversibility window. Same value the appview uses for
|
||||
@@ -135,7 +133,6 @@ services:
|
||||
LABELER_LABELER_PUBLIC_URL: http://172.28.0.4:5002
|
||||
LABELER_LABELER_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
|
||||
LABELER_LABELER_DATA_DIR: /var/lib/atcr-labeler
|
||||
LABELER_SERVER_TEST_MODE: true
|
||||
LABELER_LOG_LEVEL: debug
|
||||
LOG_SHIPPER_BACKEND: victoria
|
||||
LOG_SHIPPER_URL: http://172.28.0.10:9428
|
||||
|
||||
@@ -185,7 +185,6 @@ shorthand):
|
||||
| `ATCR_AUTH_CERT_PATH` | `auth.cert_path` |
|
||||
| `ATCR_JETSTREAM_BACKFILL_ENABLED` | `jetstream.backfill_enabled` |
|
||||
| `ATCR_LABELER_DID` | `labeler.did` |
|
||||
| `ATCR_SERVER_TEST_MODE` | `server.test_mode` |
|
||||
| `ATCR_LOG_LEVEL` | `log.level` |
|
||||
|
||||
There is **no** `ATCR_DEV_MODE` variable anywhere in the codebase. Likewise
|
||||
|
||||
@@ -125,9 +125,10 @@ forged crew records placing a fake hold in targeted users' member lists. It does
|
||||
defend against a malicious actor running a real hold service — that is inherent to open
|
||||
federation, same as any open-registration hold.
|
||||
|
||||
In test mode (`SetTestMode(true)`), local `did:web` identifiers that the indigo
|
||||
directory cannot resolve (HTTP, IP:port) are trusted, matching the
|
||||
`ResolveHoldDIDToURL` fallback.
|
||||
In a `-tags testmode` build, local `did:web` identifiers (HTTP, IP:port,
|
||||
localhost) resolve through the directory wrapper in `pkg/atproto/indigo_local.go`,
|
||||
so the same verification applies to them. A production build cannot resolve
|
||||
them at all.
|
||||
|
||||
## Data Model
|
||||
|
||||
|
||||
+3
-1
@@ -278,9 +278,11 @@ log_level: debug
|
||||
server:
|
||||
managed_holds:
|
||||
- "did:web:127.0.0.1:8080"
|
||||
test_mode: true # allows HTTP for DID resolution
|
||||
```
|
||||
|
||||
Resolving the loopback `did:web` above needs a `-tags testmode` build; see
|
||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
Run a hold service locally with Minio for S3-compatible storage. See [hold.md](hold.md) for hold setup.
|
||||
|
||||
## Web Interface
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ recover from it, and a proposed automatic fix that is **not implemented**.
|
||||
**1. The hold announces itself once, at boot.**
|
||||
|
||||
`ServeWithListener` fires a single `requestCrawls()` goroutine during startup
|
||||
(`pkg/hold/server.go:400`), skipped entirely when `server.test_mode` is set because local
|
||||
(`pkg/hold/server.go:400`), skipped entirely in a `-tags testmode` build because local
|
||||
dev holds are not reachable by public relays. The implementation
|
||||
(`pkg/hold/server.go:448-483`) builds a deduplicated target list from
|
||||
`atproto.KnownRelays` (`pkg/atproto/relays.go:22`, the hardcoded list also documented in
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Package testharness boots an in-process ATCR stack (fake PDS, gofakes3,
|
||||
// hold, appview) for integration smoke tests. It exposes thin helpers for
|
||||
// adding sailors and obtaining basic-auth credentials for an OCI registry
|
||||
// client — either a library-specific authn.Authenticator (RegistryAuth) or
|
||||
// a neutral Auth value (RegistryCreds) consumed by the client-agnostic
|
||||
// matrix in test/integration.
|
||||
//
|
||||
// The stack's holds and appview identify themselves by did:web on loopback
|
||||
// ports, which only a `-tags testmode` build resolves (see
|
||||
// pkg/atproto/indigo_local.go). harness.go carries that build constraint, so
|
||||
// an untagged build sees an empty package and any importer fails to compile
|
||||
// rather than dying on an opaque dial error at runtime.
|
||||
package testharness
|
||||
@@ -1,9 +1,5 @@
|
||||
// Package testharness boots an in-process ATCR stack (fake PDS, gofakes3,
|
||||
// hold, appview) for integration smoke tests. It exposes thin helpers for
|
||||
// adding sailors and obtaining basic-auth credentials for an OCI registry
|
||||
// client — either a library-specific authn.Authenticator (RegistryAuth) or
|
||||
// a neutral Auth value (RegistryCreds) consumed by the client-agnostic
|
||||
// matrix in test/integration.
|
||||
//go:build testmode
|
||||
|
||||
package testharness
|
||||
|
||||
import (
|
||||
@@ -117,23 +113,13 @@ func New(t *testing.T, opts ...Option) *Harness {
|
||||
}
|
||||
h := &Harness{t: t}
|
||||
|
||||
// The stack's holds and appview identify themselves by did:web on loopback
|
||||
// ports. Only a `-tags testmode` build resolves those (pkg/atproto's
|
||||
// indigo_local.go); a production build would fail every lookup with an
|
||||
// opaque dial error, so say why up front.
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("the integration harness needs a `-tags testmode` build: go test -tags integration,testmode ./test/integration/...")
|
||||
}
|
||||
|
||||
// 1. Fake PDS.
|
||||
h.PDS = testpds.New(t)
|
||||
atproto.SetDirectory(h.PDS.Directory())
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() {
|
||||
// Reset to a fresh default so a later non-test process won't see our
|
||||
// fake. SetDirectory(nil) re-arms lazy init in GetDirectory.
|
||||
atproto.SetDirectory(nil)
|
||||
atproto.SetTestMode(false)
|
||||
})
|
||||
|
||||
// 2. gofakes3 (S3-compatible in-memory).
|
||||
@@ -190,7 +176,6 @@ func New(t *testing.T, opts ...Option) *Harness {
|
||||
Addr: holdAddr,
|
||||
PublicURL: holdPublicURL,
|
||||
Public: !o.privateHold, // public: anyone may pull, crew may push. private: crew only, both ways.
|
||||
TestMode: true,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
},
|
||||
@@ -438,7 +423,6 @@ func buildAppViewConfig(addr, baseURL, holdDID, dbPath string) *appview.Config {
|
||||
cfg.Server.Addr = addr
|
||||
cfg.Server.BaseURL = baseURL
|
||||
cfg.Server.ManagedHolds = []string{holdDID}
|
||||
cfg.Server.TestMode = true
|
||||
// Registry domain is a bare hostname (no port). DomainRoutingMiddleware
|
||||
// strips ports before matching, so "127.0.0.1" is what /v2/ requests
|
||||
// will hit (since the listener binds to 127.0.0.1). BaseURL uses
|
||||
@@ -496,7 +480,6 @@ func buildDistributionConfig(addr, baseURL, holdDID string, services []string, c
|
||||
Name: "atproto-resolver",
|
||||
Options: configuration.Parameters{
|
||||
"default_hold_did": holdDID,
|
||||
"test_mode": true,
|
||||
"base_url": baseURL,
|
||||
},
|
||||
}},
|
||||
|
||||
@@ -2,11 +2,9 @@ package authgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
)
|
||||
|
||||
@@ -203,132 +201,6 @@ func TestCheckCrewBlobWrite_NullPermissions(t *testing.T) {
|
||||
|
||||
// --- checkQuota ------------------------------------------------------------
|
||||
|
||||
func TestCheckQuota_UnderLimit(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota under limit = %v, want nil", err)
|
||||
}
|
||||
if srv.hits != 1 {
|
||||
t.Errorf("expected 1 hit on quota endpoint, got %d", srv.hits)
|
||||
}
|
||||
// The query value is percent-encoded by the client (see
|
||||
// TestCheckQuota_EncodesUserDID), so plain "did:plc:alice" becomes
|
||||
// "did%3Aplc%3Aalice" on the wire.
|
||||
if !strings.Contains(srv.lastURL, "userDid=did%3Aplc%3Aalice") {
|
||||
t.Errorf("expected userDid query param, got URL %q", srv.lastURL)
|
||||
}
|
||||
if !strings.Contains(srv.lastURL, atproto.HoldGetQuota) {
|
||||
t.Errorf("expected URL path to contain %q, got %q", atproto.HoldGetQuota, srv.lastURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_OverLimit(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
// 5 GiB exactly so the formatted message shows "5.00 GB / 5.00 GB".
|
||||
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:alice", "alice.bsky.social", "")
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID)
|
||||
if err == nil {
|
||||
t.Fatal("checkQuota at limit should deny")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"quota exceeded", "5.00 GB", "did:plc:alice", "alice.bsky.social"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("expected %q in error %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When no users row exists for the DID (handle unknown), the error still
|
||||
// formats correctly with the bare DID.
|
||||
func TestCheckQuota_OverLimit_NoHandle(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
err := a.checkQuota(context.Background(), "did:plc:bob", srv.holdDID)
|
||||
if err == nil {
|
||||
t.Fatal("checkQuota at limit should deny")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "did:plc:bob") || strings.Contains(msg, "(did:") {
|
||||
t.Errorf("expected bare DID (no parenthesized form) in error %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_NilLimitAllows(t *testing.T) {
|
||||
// A user on the unlimited tier has limit == nil. Even huge totalSize
|
||||
// must not deny.
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":99999999}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with nil limit = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_500FailsOpen(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 500, `oops`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with 500 should fail open, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_BadJSONFailsOpen(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `not-json`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with malformed JSON should fail open, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_EncodesUserDID(t *testing.T) {
|
||||
// did:web DIDs may contain percent-encoded characters (e.g. "%3A" for
|
||||
// the port colon). Without proper query encoding the receiving server's
|
||||
// query parser decodes "%3A" → ":", mangling the DID and missing the
|
||||
// records that were keyed by the original form. The fix encodes the
|
||||
// DID once at the client side so the server decodes it back exactly.
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
encodedDID := "did:web:127.0.0.1%3A45397:user:alice.test"
|
||||
if err := a.checkQuota(context.Background(), encodedDID, srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with encoded DID = %v, want nil", err)
|
||||
}
|
||||
// The URL the server saw should contain the double-encoded form, so
|
||||
// that its single decode pass yields the original DID back.
|
||||
if !strings.Contains(srv.lastURL, "did%3Aweb%3A127.0.0.1%253A45397%3Auser%3Aalice.test") {
|
||||
t.Errorf("expected query value to be percent-encoded; got URL %q", srv.lastURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_HoldURLResolutionFailsOpen(t *testing.T) {
|
||||
// A "did:" prefixed but otherwise malformed identifier makes
|
||||
// ResolveHoldURL → ResolveHoldDIDToURL → syntax.ParseDID error out
|
||||
@@ -361,221 +233,3 @@ func TestAuthorize_NoHoldAllowsAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_CaptainBypassesCrewCheck(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:alice", "alice.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
// A contradictory crew row should NOT trip the gate — captain bypass.
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:alice", `["blob:read"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:alice", "", pushAccess("alice/x")); err != nil {
|
||||
t.Errorf("Authorize(captain push) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushWithoutCrewDenied(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // alice owns; bob is not crew
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "crew membership required") {
|
||||
t.Errorf("expected 'crew membership required', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushWithoutBlobWriteDenied(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:read"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "lacks blob:write") {
|
||||
t.Errorf("expected 'lacks blob:write', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushUnderQuotaAllowed(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x")); err != nil {
|
||||
t.Errorf("Authorize(crew blob:write under quota) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushOverQuotaDenied(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
|
||||
t.Errorf("expected 'quota exceeded', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An over-quota user has to be able to delete: the denial message tells them
|
||||
// to, and docker/crane ask for pull,push,delete on a delete. The gate grants
|
||||
// the non-push subset instead of failing the whole request.
|
||||
func TestAuthorize_OverQuotaGrantsDeleteWithoutPush(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(over quota, delete requested) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "delete"}) {
|
||||
t.Errorf("granted actions = %v, want [pull delete]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete-only never carries push, so it must survive untouched.
|
||||
func TestAuthorize_OverQuotaAllowsDeleteOnlyScope(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(delete only) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"delete"}) {
|
||||
t.Errorf("granted actions = %v, want [delete]", got)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for delete-only scope, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain push must keep failing loudly, otherwise the client never sees the
|
||||
// quota message and just gets an opaque 401 on the first blob upload.
|
||||
func TestAuthorize_OverQuotaStillDeniesPlainPush(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := pushAccess("bob/x")
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", access)
|
||||
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
|
||||
t.Fatalf("expected 'quota exceeded', got %v", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push"}) {
|
||||
t.Errorf("denied request should leave actions untouched, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Under quota, a delete request keeps its push action.
|
||||
func TestAuthorize_UnderQuotaKeepsPushAlongsideDelete(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(under quota) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push", "delete"}) {
|
||||
t.Errorf("granted actions = %v, want all three preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_PullOnlySkipsMembershipAndQuota(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
// Quota server installed but should never be hit: pull bypasses both
|
||||
// the membership requirement and the quota call.
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // bob is NOT captain, NOT crew
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", pullAccess("alice/x")); err != nil {
|
||||
t.Errorf("Authorize(pull only) = %v, want nil", err)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for pull-only request, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_WildcardPushTreatedAsPull(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
wildcard := []auth.AccessEntry{{Type: "repository", Name: "*", Actions: []string{"pull", "push"}}}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", wildcard); err != nil {
|
||||
t.Errorf("Authorize(wildcard push) = %v, want nil (treated as pull)", err)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for wildcard scope, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//go:build testmode
|
||||
|
||||
package authgate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/testpds"
|
||||
)
|
||||
|
||||
// quotaServerResult captures HTTP traffic the server saw, for assertions.
|
||||
type quotaServerResult struct {
|
||||
server *httptest.Server
|
||||
holdDID string // did:web:127.0.0.1%3APORT form
|
||||
hits int
|
||||
lastURL string
|
||||
}
|
||||
|
||||
// quotaServer spins up an httptest.Server that responds to every request
|
||||
// with the given status + body, records hit count + last URL, and returns
|
||||
// both the server URL and the did:web:HOST form that resolves to it. The
|
||||
// server also serves its own did:web document (not counted in hits) so the
|
||||
// test-mode identity directory can resolve that DID back to the server.
|
||||
func quotaServer(t *testing.T, status int, body string) *quotaServerResult {
|
||||
t.Helper()
|
||||
res := "aServerResult{}
|
||||
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.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
res.hits++
|
||||
res.lastURL = r.URL.String()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
res.server = httptest.NewServer(mux)
|
||||
t.Cleanup(res.server.Close)
|
||||
|
||||
// httptest.Server.URL has the form "http://127.0.0.1:PORT"; did:web
|
||||
// percent-encodes the port colon.
|
||||
res.holdDID = testpds.DIDWebForURL(res.server.URL)
|
||||
return res
|
||||
}
|
||||
|
||||
// httpClient returns the server's client, which trusts its TLS cert (n/a
|
||||
// here since httptest.NewServer is HTTP) and routes to the loopback.
|
||||
func (r *quotaServerResult) httpClient() *http.Client {
|
||||
return r.server.Client()
|
||||
}
|
||||
|
||||
func TestCheckQuota_UnderLimit(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota under limit = %v, want nil", err)
|
||||
}
|
||||
if srv.hits != 1 {
|
||||
t.Errorf("expected 1 hit on quota endpoint, got %d", srv.hits)
|
||||
}
|
||||
// The query value is percent-encoded by the client (see
|
||||
// TestCheckQuota_EncodesUserDID), so plain "did:plc:alice" becomes
|
||||
// "did%3Aplc%3Aalice" on the wire.
|
||||
if !strings.Contains(srv.lastURL, "userDid=did%3Aplc%3Aalice") {
|
||||
t.Errorf("expected userDid query param, got URL %q", srv.lastURL)
|
||||
}
|
||||
if !strings.Contains(srv.lastURL, atproto.HoldGetQuota) {
|
||||
t.Errorf("expected URL path to contain %q, got %q", atproto.HoldGetQuota, srv.lastURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_OverLimit(t *testing.T) {
|
||||
// 5 GiB exactly so the formatted message shows "5.00 GB / 5.00 GB".
|
||||
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:alice", "alice.bsky.social", "")
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID)
|
||||
if err == nil {
|
||||
t.Fatal("checkQuota at limit should deny")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"quota exceeded", "5.00 GB", "did:plc:alice", "alice.bsky.social"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("expected %q in error %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When no users row exists for the DID (handle unknown), the error still
|
||||
// formats correctly with the bare DID.
|
||||
func TestCheckQuota_OverLimit_NoHandle(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":5368709120,"limit":5368709120}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
err := a.checkQuota(context.Background(), "did:plc:bob", srv.holdDID)
|
||||
if err == nil {
|
||||
t.Fatal("checkQuota at limit should deny")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "did:plc:bob") || strings.Contains(msg, "(did:") {
|
||||
t.Errorf("expected bare DID (no parenthesized form) in error %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_NilLimitAllows(t *testing.T) {
|
||||
// A user on the unlimited tier has limit == nil. Even huge totalSize
|
||||
// must not deny.
|
||||
srv := quotaServer(t, 200, `{"totalSize":99999999}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with nil limit = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_500FailsOpen(t *testing.T) {
|
||||
srv := quotaServer(t, 500, `oops`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with 500 should fail open, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_BadJSONFailsOpen(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `not-json`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
if err := a.checkQuota(context.Background(), "did:plc:alice", srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with malformed JSON should fail open, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuota_EncodesUserDID(t *testing.T) {
|
||||
// did:web DIDs may contain percent-encoded characters (e.g. "%3A" for
|
||||
// the port colon). Without proper query encoding the receiving server's
|
||||
// query parser decodes "%3A" → ":", mangling the DID and missing the
|
||||
// records that were keyed by the original form. The fix encodes the
|
||||
// DID once at the client side so the server decodes it back exactly.
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
a := New(newTestDB(t), fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
|
||||
encodedDID := "did:web:127.0.0.1%3A45397:user:alice.test"
|
||||
if err := a.checkQuota(context.Background(), encodedDID, srv.holdDID); err != nil {
|
||||
t.Errorf("checkQuota with encoded DID = %v, want nil", err)
|
||||
}
|
||||
// The URL the server saw should contain the double-encoded form, so
|
||||
// that its single decode pass yields the original DID back.
|
||||
if !strings.Contains(srv.lastURL, "did%3Aweb%3A127.0.0.1%253A45397%3Auser%3Aalice.test") {
|
||||
t.Errorf("expected query value to be percent-encoded; got URL %q", srv.lastURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_CaptainBypassesCrewCheck(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:alice", "alice.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
// A contradictory crew row should NOT trip the gate — captain bypass.
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:alice", `["blob:read"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:alice", "", pushAccess("alice/x")); err != nil {
|
||||
t.Errorf("Authorize(captain push) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushWithoutCrewDenied(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // alice owns; bob is not crew
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "crew membership required") {
|
||||
t.Errorf("expected 'crew membership required', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushWithoutBlobWriteDenied(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:read"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "lacks blob:write") {
|
||||
t.Errorf("expected 'lacks blob:write', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushUnderQuotaAllowed(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":100,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x")); err != nil {
|
||||
t.Errorf("Authorize(crew blob:write under quota) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NonCaptainPushOverQuotaDenied(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", pushAccess("bob/x"))
|
||||
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
|
||||
t.Errorf("expected 'quota exceeded', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An over-quota user has to be able to delete: the denial message tells them
|
||||
// to, and docker/crane ask for pull,push,delete on a delete. The gate grants
|
||||
// the non-push subset instead of failing the whole request.
|
||||
func TestAuthorize_OverQuotaGrantsDeleteWithoutPush(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(over quota, delete requested) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "delete"}) {
|
||||
t.Errorf("granted actions = %v, want [pull delete]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete-only never carries push, so it must survive untouched.
|
||||
func TestAuthorize_OverQuotaAllowsDeleteOnlyScope(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(delete only) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"delete"}) {
|
||||
t.Errorf("granted actions = %v, want [delete]", got)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for delete-only scope, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain push must keep failing loudly, otherwise the client never sees the
|
||||
// quota message and just gets an opaque 401 on the first blob upload.
|
||||
func TestAuthorize_OverQuotaStillDeniesPlainPush(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := pushAccess("bob/x")
|
||||
err := a.Authorize(context.Background(), "did:plc:bob", "", access)
|
||||
if err == nil || !strings.Contains(err.Error(), "quota exceeded") {
|
||||
t.Fatalf("expected 'quota exceeded', got %v", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push"}) {
|
||||
t.Errorf("denied request should leave actions untouched, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Under quota, a delete request keeps its push action.
|
||||
func TestAuthorize_UnderQuotaKeepsPushAlongsideDelete(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
seedCrewMember(t, d, srv.holdDID, "did:plc:bob", `["blob:write"]`)
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
access := []auth.AccessEntry{
|
||||
{Type: "repository", Name: "bob/x", Actions: []string{"pull", "push", "delete"}},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", access); err != nil {
|
||||
t.Fatalf("Authorize(under quota) = %v, want nil", err)
|
||||
}
|
||||
if got := access[0].Actions; !slices.Equal(got, []string{"pull", "push", "delete"}) {
|
||||
t.Errorf("granted actions = %v, want all three preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_PullOnlySkipsMembershipAndQuota(t *testing.T) {
|
||||
// Quota server installed but should never be hit: pull bypasses both
|
||||
// the membership requirement and the quota call.
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice") // bob is NOT captain, NOT crew
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", pullAccess("alice/x")); err != nil {
|
||||
t.Errorf("Authorize(pull only) = %v, want nil", err)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for pull-only request, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_WildcardPushTreatedAsPull(t *testing.T) {
|
||||
srv := quotaServer(t, 200, `{"totalSize":1000,"limit":1000}`)
|
||||
d := newTestDB(t)
|
||||
seedUser(t, d, "did:plc:bob", "bob.test", srv.holdDID)
|
||||
seedCaptain(t, d, srv.holdDID, "did:plc:alice")
|
||||
|
||||
a := New(d, fakeHoldAuthorizer{}, nil, "", WithHTTPClient(srv.httpClient()))
|
||||
wildcard := []auth.AccessEntry{{Type: "repository", Name: "*", Actions: []string{"pull", "push"}}}
|
||||
if err := a.Authorize(context.Background(), "did:plc:bob", "", wildcard); err != nil {
|
||||
t.Errorf("Authorize(wildcard push) = %v, want nil (treated as pull)", err)
|
||||
}
|
||||
if srv.hits != 0 {
|
||||
t.Errorf("quota endpoint hit %d times for wildcard scope, want 0", srv.hits)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,11 @@ package authgate
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/testpds"
|
||||
)
|
||||
|
||||
// newTestDB returns an in-memory libsql DB with the full appview schema
|
||||
@@ -62,52 +59,6 @@ func seedCrewMember(t *testing.T, d *sql.DB, holdDID, memberDID, permsJSON strin
|
||||
}
|
||||
}
|
||||
|
||||
// quotaServerResult captures HTTP traffic the server saw, for assertions.
|
||||
type quotaServerResult struct {
|
||||
server *httptest.Server
|
||||
holdDID string // did:web:127.0.0.1%3APORT form
|
||||
hits int
|
||||
lastURL string
|
||||
}
|
||||
|
||||
// quotaServer spins up an httptest.Server that responds to every request
|
||||
// with the given status + body, records hit count + last URL, and returns
|
||||
// both the server URL and the did:web:HOST form that resolves to it. The
|
||||
// server also serves its own did:web document (not counted in hits) so the
|
||||
// test-mode identity directory can resolve that DID back to the server.
|
||||
func quotaServer(t *testing.T, status int, body string) *quotaServerResult {
|
||||
t.Helper()
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
|
||||
}
|
||||
res := "aServerResult{}
|
||||
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.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
res.hits++
|
||||
res.lastURL = r.URL.String()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
res.server = httptest.NewServer(mux)
|
||||
t.Cleanup(res.server.Close)
|
||||
|
||||
// httptest.Server.URL has the form "http://127.0.0.1:PORT"; did:web
|
||||
// percent-encodes the port colon.
|
||||
res.holdDID = testpds.DIDWebForURL(res.server.URL)
|
||||
return res
|
||||
}
|
||||
|
||||
// httpClient returns the server's client, which trusts its TLS cert (n/a
|
||||
// here since httptest.NewServer is HTTP) and routes to the loopback.
|
||||
func (r *quotaServerResult) httpClient() *http.Client {
|
||||
return r.server.Client()
|
||||
}
|
||||
|
||||
// fakeHoldAuthorizer is a no-op auth.HoldAuthorizer stub. The Authorize
|
||||
// orchestration tests don't exercise the reconciliation closure (the
|
||||
// closure is nil for our purposes because we don't supply a refresher
|
||||
|
||||
@@ -55,9 +55,6 @@ type ServerConfig struct {
|
||||
// Public-facing URL for OAuth callbacks and JWT realm.
|
||||
BaseURL string `yaml:"base_url" comment:"Public-facing URL for OAuth callbacks and JWT realm. Auto-detected if empty."`
|
||||
|
||||
// Allows HTTP (not HTTPS) for DID resolution.
|
||||
TestMode bool `yaml:"test_mode" comment:"Local development only. Routes pushes to the default hold when the user's chosen hold is unreachable and quiets backfill warnings about external holds. Does not affect DID resolution: that needs a -tags testmode build."`
|
||||
|
||||
// Display name shown on OAuth authorization screens.
|
||||
ClientName string `yaml:"client_name" comment:"Display name shown on OAuth authorization screens."`
|
||||
|
||||
@@ -249,7 +246,6 @@ func setDefaults(v *viper.Viper) {
|
||||
// Server defaults
|
||||
v.SetDefault("server.addr", ":5000")
|
||||
v.SetDefault("server.base_url", "")
|
||||
v.SetDefault("server.test_mode", false)
|
||||
v.SetDefault("server.client_name", "AT Container Registry")
|
||||
v.SetDefault("server.client_short_name", "ATCR")
|
||||
v.SetDefault("server.registry_domains", []string{})
|
||||
@@ -486,7 +482,7 @@ func buildDistributionConfig(cfg *Config, v *viper.Viper) (*configuration.Config
|
||||
distConfig.Storage = buildStorageConfig()
|
||||
|
||||
// Middleware (ATProto resolver)
|
||||
distConfig.Middleware = buildMiddlewareConfig(cfg.Server.PrimaryHoldDID(), cfg.Server.BaseURL, cfg.Server.TestMode)
|
||||
distConfig.Middleware = buildMiddlewareConfig(cfg.Server.PrimaryHoldDID(), cfg.Server.BaseURL)
|
||||
|
||||
// Auth (use values from cfg.Auth)
|
||||
//
|
||||
@@ -566,14 +562,13 @@ func buildStorageConfig() configuration.Storage {
|
||||
}
|
||||
|
||||
// buildMiddlewareConfig creates middleware configuration
|
||||
func buildMiddlewareConfig(defaultHoldDID string, baseURL string, testMode bool) map[string][]configuration.Middleware {
|
||||
func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware {
|
||||
return map[string][]configuration.Middleware{
|
||||
"registry": {
|
||||
{
|
||||
Name: "atproto-resolver",
|
||||
Options: configuration.Parameters{
|
||||
"default_hold_did": defaultHoldDID,
|
||||
"test_mode": testMode,
|
||||
"base_url": baseURL,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -100,28 +100,17 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
name string
|
||||
defaultHoldDID string
|
||||
baseURL string
|
||||
testMode bool
|
||||
wantTestMode bool
|
||||
}{
|
||||
{
|
||||
name: "normal mode",
|
||||
name: "default hold and base URL",
|
||||
defaultHoldDID: "did:web:hold01.atcr.io",
|
||||
baseURL: "https://atcr.io",
|
||||
testMode: false,
|
||||
wantTestMode: false,
|
||||
},
|
||||
{
|
||||
name: "test mode enabled",
|
||||
defaultHoldDID: "did:web:hold01.atcr.io",
|
||||
baseURL: "https://atcr.io",
|
||||
testMode: true,
|
||||
wantTestMode: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildMiddlewareConfig(tt.defaultHoldDID, tt.baseURL, tt.testMode)
|
||||
got := buildMiddlewareConfig(tt.defaultHoldDID, tt.baseURL)
|
||||
|
||||
registryMW, ok := got["registry"]
|
||||
if !ok {
|
||||
@@ -144,10 +133,6 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
if mw.Options["base_url"] != tt.baseURL {
|
||||
t.Errorf("base_url = %v, want %v", mw.Options["base_url"], tt.baseURL)
|
||||
}
|
||||
|
||||
if mw.Options["test_mode"] != tt.wantTestMode {
|
||||
t.Errorf("test_mode = %v, want %v", mw.Options["test_mode"], tt.wantTestMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,10 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/testpds"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
@@ -89,148 +86,3 @@ func TestUpdateCrewTierOnHold_PostsToEndpoint(t *testing.T) {
|
||||
// The tests above cover the two helpers. UpdateCrewTierOnAllHolds — the
|
||||
// function the Stripe webhook actually calls, and the one whose error decides
|
||||
// whether a paid upgrade is retried or dropped — had no test at all.
|
||||
|
||||
// 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.
|
||||
func holdServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
|
||||
}
|
||||
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) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
_, 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) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
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) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ type BackfillWorker struct {
|
||||
endpoints *EndpointRotator
|
||||
processor *Processor // Shared processor for DB operations
|
||||
defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io")
|
||||
testMode bool // If true, suppress warnings for external holds
|
||||
refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil)
|
||||
|
||||
// captainChecked tracks the last time we successfully fetched each hold's
|
||||
@@ -56,7 +55,7 @@ type BackfillState struct {
|
||||
// defaultHoldDID should be in format "did:web:hold01.atcr.io"
|
||||
// To find a hold's DID, visit: https://hold-url/.well-known/did.json
|
||||
// refresher is optional - if provided, backfill will try to update PDS records when fetching README content
|
||||
func NewBackfillWorker(database *sql.DB, relayEndpoints []string, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) {
|
||||
func NewBackfillWorker(database *sql.DB, relayEndpoints []string, defaultHoldDID string, refresher *oauth.Refresher) (*BackfillWorker, error) {
|
||||
if len(relayEndpoints) == 0 {
|
||||
relayEndpoints = []string{"https://relay1.us-east.bsky.network"}
|
||||
}
|
||||
@@ -66,7 +65,6 @@ func NewBackfillWorker(database *sql.DB, relayEndpoints []string, defaultHoldDID
|
||||
endpoints: NewEndpointRotator(relayEndpoints),
|
||||
processor: NewProcessor(database, false, NewStatsCache()), // Stats cache for aggregation
|
||||
defaultHoldDID: defaultHoldDID,
|
||||
testMode: testMode,
|
||||
refresher: refresher,
|
||||
captainChecked: make(map[string]time.Time),
|
||||
}, nil
|
||||
@@ -479,10 +477,9 @@ func (b *BackfillWorker) processRecordWith(ctx context.Context, proc *Processor,
|
||||
// queryCaptainRecordWrapper wraps queryCaptainRecord with backfill-specific logic
|
||||
func (b *BackfillWorker) queryCaptainRecordWrapper(ctx context.Context, holdDID string) error {
|
||||
if err := b.queryCaptainRecord(ctx, holdDID); err != nil {
|
||||
// In test mode, only warn about default hold (local hold)
|
||||
// In a testmode build, only warn about the default (local) hold.
|
||||
// External/production holds may not have captain records yet (dev ahead of prod)
|
||||
if b.testMode && holdDID != b.defaultHoldDID {
|
||||
// Suppress warning for external holds in test mode
|
||||
if atproto.TestModeBuild && holdDID != b.defaultHoldDID {
|
||||
return nil
|
||||
}
|
||||
slog.Warn("Backfill failed to query captain record for hold", "hold_did", holdDID, "error", err)
|
||||
|
||||
@@ -257,17 +257,17 @@ func init() {
|
||||
// NamespaceResolver wraps a namespace and resolves names
|
||||
type NamespaceResolver struct {
|
||||
distribution.Namespace
|
||||
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
|
||||
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
|
||||
testMode bool // If true, fallback to default hold when user's hold is unreachable
|
||||
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
|
||||
database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
|
||||
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
|
||||
webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
|
||||
manifestRefChecker storage.ManifestReferenceChecker // Manifest reference checker (copied from global on init)
|
||||
validationCache *validationCache // Request-level service token cache
|
||||
readmeFetcher *readme.Fetcher // README fetcher for repo pages
|
||||
userPrefs UserPrefsCache // Cached sailor profile preferences (copied from global on init)
|
||||
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
|
||||
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
|
||||
fallbackUnreachable bool // Fall back to the default hold when the user's hold is unreachable (testmode builds)
|
||||
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
|
||||
database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init)
|
||||
authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
|
||||
webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init)
|
||||
manifestRefChecker storage.ManifestReferenceChecker // Manifest reference checker (copied from global on init)
|
||||
validationCache *validationCache // Request-level service token cache
|
||||
readmeFetcher *readme.Fetcher // README fetcher for repo pages
|
||||
userPrefs UserPrefsCache // Cached sailor profile preferences (copied from global on init)
|
||||
}
|
||||
|
||||
// initATProtoResolver initializes the name resolution middleware
|
||||
@@ -285,27 +285,21 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
|
||||
baseURL = url
|
||||
}
|
||||
|
||||
// Check test mode from options (passed via env var)
|
||||
testMode := false
|
||||
if tm, ok := options["test_mode"].(bool); ok {
|
||||
testMode = tm
|
||||
}
|
||||
|
||||
// Copy shared services from globals into the instance
|
||||
// This avoids accessing globals during request handling
|
||||
return &NamespaceResolver{
|
||||
Namespace: ns,
|
||||
defaultHoldDID: defaultHoldDID,
|
||||
baseURL: baseURL,
|
||||
testMode: testMode,
|
||||
refresher: globalRefresher,
|
||||
database: globalDatabase,
|
||||
authorizer: globalAuthorizer,
|
||||
webhookDispatcher: globalWebhookDispatcher,
|
||||
manifestRefChecker: globalManifestRefChecker,
|
||||
validationCache: newValidationCache(),
|
||||
readmeFetcher: readme.NewFetcher(),
|
||||
userPrefs: globalUserPrefs,
|
||||
Namespace: ns,
|
||||
defaultHoldDID: defaultHoldDID,
|
||||
baseURL: baseURL,
|
||||
fallbackUnreachable: atproto.TestModeBuild,
|
||||
refresher: globalRefresher,
|
||||
database: globalDatabase,
|
||||
authorizer: globalAuthorizer,
|
||||
webhookDispatcher: globalWebhookDispatcher,
|
||||
manifestRefChecker: globalManifestRefChecker,
|
||||
validationCache: newValidationCache(),
|
||||
readmeFetcher: readme.NewFetcher(),
|
||||
userPrefs: globalUserPrefs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -769,14 +763,14 @@ func (nr *NamespaceResolver) learnHoldPrefs(ctx context.Context, did, handle, pd
|
||||
}
|
||||
|
||||
// applyTestModeFallback turns a user's chosen hold into the hold to actually
|
||||
// use. An empty choice means the appview default. In test mode a chosen hold
|
||||
// that is not answering also falls back, so a developer whose local hold is
|
||||
// down can still push.
|
||||
// use. An empty choice means the appview default. With fallbackUnreachable set
|
||||
// (testmode builds) a chosen hold that is not answering also falls back, so a
|
||||
// developer whose local hold is down can still push.
|
||||
func (nr *NamespaceResolver) applyTestModeFallback(ctx context.Context, userHoldDID string) string {
|
||||
if userHoldDID == "" {
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
if nr.testMode && !nr.isHoldReachable(ctx, userHoldDID) {
|
||||
if nr.fallbackUnreachable && !nr.isHoldReachable(ctx, userHoldDID) {
|
||||
slog.Debug("User's defaultHold unreachable, falling back to default",
|
||||
"component", "registry/middleware/testmode", "default_hold", userHoldDID)
|
||||
return nr.defaultHoldDID
|
||||
|
||||
@@ -79,16 +79,6 @@ func TestInitATProtoResolver(t *testing.T) {
|
||||
options: map[string]any{
|
||||
"default_hold_did": "did:web:hold01.atcr.io",
|
||||
"base_url": "https://atcr.io",
|
||||
"test_mode": false,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "with test mode enabled",
|
||||
options: map[string]any{
|
||||
"default_hold_did": "did:web:hold01.atcr.io",
|
||||
"base_url": "https://atcr.io",
|
||||
"test_mode": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -119,9 +109,7 @@ func TestInitATProtoResolver(t *testing.T) {
|
||||
if baseURL, ok := tt.options["base_url"].(string); ok {
|
||||
assert.Equal(t, baseURL, resolver.baseURL)
|
||||
}
|
||||
if testMode, ok := tt.options["test_mode"].(bool); ok {
|
||||
assert.Equal(t, testMode, resolver.testMode)
|
||||
}
|
||||
assert.Equal(t, atproto.TestModeBuild, resolver.fallbackUnreachable)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -187,7 +175,6 @@ func TestFindHoldDID_SailorProfile(t *testing.T) {
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
testMode: false,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -224,7 +211,7 @@ func TestFindHoldDID_Priority(t *testing.T) {
|
||||
assert.Equal(t, "did:web:profile.hold.io", holdDID, "should prioritize sailor profile over hold records")
|
||||
}
|
||||
|
||||
// TestFindHoldDID_TestModeFallback tests test mode fallback when hold unreachable
|
||||
// TestFindHoldDID_TestModeFallback tests the testmode-build fallback when the hold is unreachable
|
||||
func TestFindHoldDID_TestModeFallback(t *testing.T) {
|
||||
// Start a mock PDS server that returns a profile with unreachable hold
|
||||
mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -242,15 +229,15 @@ func TestFindHoldDID_TestModeFallback(t *testing.T) {
|
||||
defer mockPDS.Close()
|
||||
|
||||
resolver := &NamespaceResolver{
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
testMode: true, // Test mode enabled
|
||||
defaultHoldDID: "did:web:default.atcr.io",
|
||||
fallbackUnreachable: true, // what a testmode build sets
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, _ := resolver.findHoldDIDAndPrefs(ctx, "did:plc:test123", "test.example.com", mockPDS.URL)
|
||||
|
||||
// In test mode with unreachable hold, should fall back to default
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default in test mode when hold unreachable")
|
||||
// In a testmode build with an unreachable hold, should fall back to default
|
||||
assert.Equal(t, "did:web:default.atcr.io", holdDID, "should fall back to default in a testmode build when hold unreachable")
|
||||
}
|
||||
|
||||
// TestIsHoldReachable tests the hold reachability check
|
||||
|
||||
@@ -226,12 +226,10 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
|
||||
baseURL := cfg.Server.BaseURL
|
||||
defaultHoldDID := cfg.Server.PrimaryHoldDID()
|
||||
testMode := cfg.Server.TestMode
|
||||
|
||||
slog.Debug("Base URL for OAuth", "base_url", baseURL)
|
||||
if testMode {
|
||||
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution")
|
||||
atproto.SetTestMode(true)
|
||||
if atproto.TestModeBuild {
|
||||
slog.Info("testmode build: local did:web resolution and loopback OAuth enabled")
|
||||
}
|
||||
|
||||
oauthKey, err := loadOAuthKey(s.Database)
|
||||
@@ -276,7 +274,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
middleware.SetGlobalLabelChecker(db.NewLabelChecker(s.Database))
|
||||
|
||||
// Create RemoteHoldAuthorizer for hold authorization with caching
|
||||
s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database, testMode)
|
||||
s.HoldAuthorizer = auth.NewRemoteHoldAuthorizer(s.Database)
|
||||
middleware.SetGlobalAuthorizer(s.HoldAuthorizer)
|
||||
slog.Info("Hold authorizer initialized with database caching")
|
||||
|
||||
@@ -788,7 +786,7 @@ func (s *AppViewServer) ServeWithListener(listener net.Listener) error {
|
||||
case <-stop:
|
||||
slog.Info("Shutting down registry server")
|
||||
|
||||
if s.Config.Server.TestMode {
|
||||
if atproto.TestModeBuild {
|
||||
listener.Close()
|
||||
}
|
||||
|
||||
@@ -1119,9 +1117,8 @@ func (s *AppViewServer) initializeJetstream(ctx context.Context) {
|
||||
if s.Config.Jetstream.BackfillEnabled {
|
||||
relayEndpoints := s.Config.Jetstream.RelayEndpoints
|
||||
defaultHoldDID := s.Config.Server.PrimaryHoldDID()
|
||||
testMode := s.Config.Server.TestMode
|
||||
|
||||
backfillWorker, err := jetstream.NewBackfillWorker(s.Database, relayEndpoints, defaultHoldDID, testMode, s.Refresher)
|
||||
backfillWorker, err := jetstream.NewBackfillWorker(s.Database, relayEndpoints, defaultHoldDID, s.Refresher)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -11,23 +11,8 @@ var (
|
||||
// call. Tests may swap it out via SetDirectory().
|
||||
sharedDirectory identity.Directory
|
||||
directoryMu sync.Mutex
|
||||
|
||||
// testMode allows HTTP did:web resolution (IPs, non-TLS) for local development.
|
||||
// Set via SetTestMode() on startup.
|
||||
testMode bool
|
||||
)
|
||||
|
||||
// SetTestMode enables relaxed did:web resolution for local development,
|
||||
// allowing HTTP and IP-based did:web identifiers that the indigo directory rejects.
|
||||
func SetTestMode(enabled bool) {
|
||||
testMode = enabled
|
||||
}
|
||||
|
||||
// IsTestMode returns whether test mode is enabled.
|
||||
func IsTestMode() bool {
|
||||
return testMode
|
||||
}
|
||||
|
||||
// SetDirectory replaces the shared identity.Directory used by all resolver
|
||||
// helpers. Intended for tests that wire in a fake directory. Production code
|
||||
// should never call this — leaving the default lazy-initialized indigo
|
||||
|
||||
@@ -40,7 +40,7 @@ func concurrentTestDB(t *testing.T) *sql.DB {
|
||||
func TestCacheDenialConcurrentIncrementsAreNotLost(t *testing.T) {
|
||||
testDB := concurrentTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false,
|
||||
testDB,
|
||||
time.Hour, // firstDenialBackoff
|
||||
time.Hour, // cleanupInterval
|
||||
time.Hour, // cleanupGracePeriod
|
||||
@@ -101,7 +101,7 @@ func TestCacheDenialBackoffMatchesLadder(t *testing.T) {
|
||||
testDB := concurrentTestDB(t)
|
||||
ladder := []time.Duration{2 * time.Second, 30 * time.Second, 5 * time.Minute}
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false, time.Hour, time.Hour, time.Hour, ladder,
|
||||
testDB, time.Hour, time.Hour, time.Hour, ladder,
|
||||
).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestCacheDenialBackoffMatchesLadder(t *testing.T) {
|
||||
func TestCacheDenialBlocksAfterPersisting(t *testing.T) {
|
||||
testDB := concurrentTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false, time.Hour, time.Hour, time.Hour,
|
||||
testDB, time.Hour, time.Hour, time.Hour,
|
||||
[]time.Duration{time.Hour},
|
||||
).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
@@ -27,7 +27,6 @@ type RemoteHoldAuthorizer struct {
|
||||
cacheTTL time.Duration // TTL for captain record cache
|
||||
recentDenials sync.Map // In-memory cache for first denials
|
||||
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
||||
testMode bool // If true, use HTTP for local DIDs
|
||||
firstDenialBackoff time.Duration // Backoff duration for first denial (default: 10s)
|
||||
cleanupInterval time.Duration // Cleanup goroutine interval (default: 10s)
|
||||
cleanupGracePeriod time.Duration // Grace period before cleanup (default: 5s)
|
||||
@@ -40,8 +39,8 @@ type denialEntry struct {
|
||||
}
|
||||
|
||||
// NewRemoteHoldAuthorizer creates a new remote authorizer for AppView with production defaults
|
||||
func NewRemoteHoldAuthorizer(db *sql.DB, testMode bool) HoldAuthorizer {
|
||||
return NewRemoteHoldAuthorizerWithBackoffs(db, testMode,
|
||||
func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
|
||||
return NewRemoteHoldAuthorizerWithBackoffs(db,
|
||||
10*time.Second, // firstDenialBackoff
|
||||
10*time.Second, // cleanupInterval
|
||||
5*time.Second, // cleanupGracePeriod
|
||||
@@ -56,7 +55,7 @@ func NewRemoteHoldAuthorizer(db *sql.DB, testMode bool) HoldAuthorizer {
|
||||
|
||||
// NewRemoteHoldAuthorizerWithBackoffs creates a new remote authorizer with custom backoff durations
|
||||
// Used for testing to avoid long sleeps
|
||||
func NewRemoteHoldAuthorizerWithBackoffs(db *sql.DB, testMode bool, firstDenialBackoff, cleanupInterval, cleanupGracePeriod time.Duration, dbBackoffDurations []time.Duration) HoldAuthorizer {
|
||||
func NewRemoteHoldAuthorizerWithBackoffs(db *sql.DB, firstDenialBackoff, cleanupInterval, cleanupGracePeriod time.Duration, dbBackoffDurations []time.Duration) HoldAuthorizer {
|
||||
a := &RemoteHoldAuthorizer{
|
||||
db: db,
|
||||
httpClient: &http.Client{
|
||||
@@ -64,7 +63,6 @@ func NewRemoteHoldAuthorizerWithBackoffs(db *sql.DB, testMode bool, firstDenialB
|
||||
},
|
||||
cacheTTL: 1 * time.Hour, // 1 hour cache TTL
|
||||
stopCleanup: make(chan struct{}),
|
||||
testMode: testMode,
|
||||
firstDenialBackoff: firstDenialBackoff,
|
||||
cleanupInterval: cleanupInterval,
|
||||
cleanupGracePeriod: cleanupGracePeriod,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testmode
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -29,7 +31,6 @@ import (
|
||||
// can resolve the DID derived from its URL (see didFromServer) back to it.
|
||||
func captainServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
requireTestModeBuild(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host
|
||||
@@ -52,16 +53,6 @@ func captainServer(t *testing.T) *httptest.Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
// requireTestModeBuild fails fast, with the reason, when the binary cannot
|
||||
// resolve a loopback did:web. Without it these tests die on an opaque dial
|
||||
// error from indigo's hardened directory.
|
||||
func requireTestModeBuild(t *testing.T) {
|
||||
t.Helper()
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
|
||||
}
|
||||
}
|
||||
|
||||
func didFromServer(url string) string {
|
||||
return "did:web:" + strings.ReplaceAll(strings.TrimPrefix(url, "http://"), ":", "%3A")
|
||||
}
|
||||
@@ -79,13 +70,10 @@ func captainRows(t *testing.T, a *RemoteHoldAuthorizer, holdDID string) int {
|
||||
|
||||
func newVerifyAuthorizer(t *testing.T) *RemoteHoldAuthorizer {
|
||||
t.Helper()
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
return &RemoteHoldAuthorizer{
|
||||
db: setupTestDB(t),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
cacheTTL: time.Hour,
|
||||
testMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,22 +14,14 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
func TestNewRemoteHoldAuthorizer_TestMode(t *testing.T) {
|
||||
// Test with testMode enabled
|
||||
authorizer := NewRemoteHoldAuthorizer(nil, true)
|
||||
func TestNewRemoteHoldAuthorizer(t *testing.T) {
|
||||
authorizer := NewRemoteHoldAuthorizer(nil)
|
||||
if authorizer == nil {
|
||||
t.Fatal("Expected non-nil authorizer")
|
||||
}
|
||||
|
||||
// Type assertion to access testMode field
|
||||
remote, ok := authorizer.(*RemoteHoldAuthorizer)
|
||||
if !ok {
|
||||
if _, ok := authorizer.(*RemoteHoldAuthorizer); !ok {
|
||||
t.Fatal("Expected *RemoteHoldAuthorizer type")
|
||||
}
|
||||
|
||||
if !remote.testMode {
|
||||
t.Error("Expected testMode to be true")
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestDB creates an in-memory database for testing
|
||||
@@ -89,7 +81,6 @@ func TestFetchCaptainRecordFromXRPC(t *testing.T) {
|
||||
// Create authorizer with test server URL as the hold DID
|
||||
remote := &RemoteHoldAuthorizer{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
testMode: true,
|
||||
}
|
||||
|
||||
// Override resolveDIDToURL to return test server URL
|
||||
@@ -116,7 +107,6 @@ func TestGetCaptainRecord_CacheHit(t *testing.T) {
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
testMode: false,
|
||||
}
|
||||
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
@@ -161,7 +151,6 @@ func TestIsCrewMember_ApprovalCacheHit(t *testing.T) {
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
testMode: false,
|
||||
}
|
||||
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
@@ -191,7 +180,6 @@ func TestIsCrewMember_DenialBackoff_FirstDenial(t *testing.T) {
|
||||
// Create authorizer with fast backoffs for testing (10ms instead of 10s)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB,
|
||||
false, // testMode
|
||||
10*time.Millisecond, // firstDenialBackoff (10ms instead of 10s)
|
||||
50*time.Millisecond, // cleanupInterval (50ms instead of 10s)
|
||||
50*time.Millisecond, // cleanupGracePeriod (50ms instead of 5s)
|
||||
@@ -240,7 +228,7 @@ func TestIsCrewMember_DenialBackoff_FirstDenial(t *testing.T) {
|
||||
func TestGetBackoffDuration(t *testing.T) {
|
||||
// Create authorizer with production backoff durations
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
tests := []struct {
|
||||
@@ -296,7 +284,7 @@ func TestCheckReadAccess_PublicHold(t *testing.T) {
|
||||
func TestClearCrewDenial_InMemory(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false,
|
||||
testDB,
|
||||
10*time.Millisecond, // firstDenialBackoff
|
||||
50*time.Millisecond, // cleanupInterval
|
||||
50*time.Millisecond, // cleanupGracePeriod
|
||||
@@ -332,7 +320,7 @@ func TestClearCrewDenial_InMemory(t *testing.T) {
|
||||
func TestClearCrewDenial_Database(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false,
|
||||
testDB,
|
||||
10*time.Millisecond, // firstDenialBackoff
|
||||
50*time.Millisecond, // cleanupInterval
|
||||
50*time.Millisecond, // cleanupGracePeriod
|
||||
@@ -372,7 +360,7 @@ func TestClearCrewDenial_Database(t *testing.T) {
|
||||
func TestDeniedUserBecomesCrewImmediateAccess(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false,
|
||||
testDB,
|
||||
1*time.Hour, // Long backoff to ensure test would fail without fix
|
||||
50*time.Millisecond,
|
||||
50*time.Millisecond,
|
||||
@@ -409,7 +397,7 @@ func TestDeniedUserBecomesCrewImmediateAccess(t *testing.T) {
|
||||
func TestClearAllDenials_OnStartup(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizerWithBackoffs(
|
||||
testDB, false,
|
||||
testDB,
|
||||
1*time.Hour, // Long backoff
|
||||
50*time.Millisecond,
|
||||
50*time.Millisecond,
|
||||
@@ -449,7 +437,7 @@ func TestClearAllDenials_OnStartup(t *testing.T) {
|
||||
|
||||
func TestIsCachedCrewMember_Hit(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
@@ -470,7 +458,7 @@ func TestIsCachedCrewMember_Hit(t *testing.T) {
|
||||
|
||||
func TestIsCachedCrewMember_Miss(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
cached, err := remote.IsCachedCrewMember(context.Background(),
|
||||
@@ -485,7 +473,7 @@ func TestIsCachedCrewMember_Miss(t *testing.T) {
|
||||
|
||||
func TestIsCachedCrewMember_Expired(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
@@ -523,7 +511,7 @@ func TestIsCachedCrewMember_Expired(t *testing.T) {
|
||||
|
||||
func TestRecordCrewApproval_WritesAndReadsBack(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(testDB).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
@@ -555,7 +543,7 @@ func TestRecordCrewApproval_WritesAndReadsBack(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsCachedCrewMember_NoDB(t *testing.T) {
|
||||
remote := NewRemoteHoldAuthorizer(nil, false).(*RemoteHoldAuthorizer)
|
||||
remote := NewRemoteHoldAuthorizer(nil).(*RemoteHoldAuthorizer)
|
||||
defer close(remote.stopCleanup)
|
||||
|
||||
cached, err := remote.IsCachedCrewMember(context.Background(),
|
||||
|
||||
@@ -3,16 +3,8 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/testpds"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/stripe/stripe-go/v84"
|
||||
)
|
||||
|
||||
@@ -99,101 +91,3 @@ func TestResolveTier_UnknownEverythingIsUnresolved(t *testing.T) {
|
||||
t.Errorf("resolveTier(nil) = (%q, %d), want (\"\", -1)", name, rank)
|
||||
}
|
||||
}
|
||||
|
||||
// managedHoldServer starts an httptest server standing in for a managed hold
|
||||
// and returns it with the did:web the tier fan-out resolves back to it. The
|
||||
// server serves its own DID document at /.well-known/did.json; handler gets
|
||||
// every other request. Resolving a loopback did:web needs a `-tags testmode`
|
||||
// build, so the test fails fast with that message otherwise.
|
||||
func managedHoldServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier runs the same
|
||||
// property through the real webhook path, so it covers the payload shape too:
|
||||
// Stripe sends price.product as a bare ID string, and this fails if that is
|
||||
// ever read wrongly.
|
||||
//
|
||||
// It asserts on the rank the managed hold is actually asked to apply. An
|
||||
// earlier draft asserted that the event was recorded as processed, and passed
|
||||
// against price-only resolution — because an unresolved tier ALSO records the
|
||||
// event and returns nil. That is the defect itself, so any assertion it
|
||||
// satisfies cannot be measuring the fix.
|
||||
func TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
const secret = "whsec_grandfather_test"
|
||||
m, _ := newTestManager(t, secret)
|
||||
m.cfg.Tiers = tierTestConfig().Tiers
|
||||
|
||||
stripeAPIReturning(t, http.StatusOK,
|
||||
`{"id":"cus_gf","object":"customer","metadata":{"user_did":"did:plc:grandfathered"}}`)
|
||||
|
||||
type tierPush struct {
|
||||
UserDID string `json:"userDid"`
|
||||
TierRank int `json:"tierRank"`
|
||||
}
|
||||
pushes := make(chan tierPush, 4)
|
||||
_, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var got tierPush
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
pushes <- got
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
m.managedHolds = []string{holdDID}
|
||||
|
||||
priv, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
m.privateKey = priv
|
||||
|
||||
// price_supporter_v1_retired appears nowhere in the config. Only its
|
||||
// product does — this is the subscriber a price change left behind.
|
||||
payload := fmt.Sprintf(`{
|
||||
"id": "evt_grandfathered",
|
||||
"object": "event",
|
||||
"api_version": %q,
|
||||
"type": "customer.subscription.updated",
|
||||
"created": %d,
|
||||
"data": {"object": {
|
||||
"id": "sub_gf",
|
||||
"object": "subscription",
|
||||
"status": "active",
|
||||
"customer": "cus_gf",
|
||||
"items": {"object":"list","data":[{"price":{
|
||||
"id":"price_supporter_v1_retired",
|
||||
"product":"prod_supporter"
|
||||
}}]}
|
||||
}}
|
||||
}`, stripe.APIVersion, time.Now().Unix())
|
||||
|
||||
if err := postWebhook(t, m, secret, []byte(payload)); err != nil {
|
||||
t.Fatalf("webhook: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-pushes:
|
||||
if got.TierRank != 1 {
|
||||
t.Errorf("hold was asked for tierRank %d, want 1 (supporter)", got.TierRank)
|
||||
}
|
||||
if got.UserDID != "did:plc:grandfathered" {
|
||||
t.Errorf("hold was asked to update %q, want did:plc:grandfathered", got.UserDID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no tier push reached the hold — the grandfathered subscriber's tier was never applied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build billing && testmode
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/testpds"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/stripe/stripe-go/v84"
|
||||
)
|
||||
|
||||
// managedHoldServer starts an httptest server standing in for a managed hold
|
||||
// and returns it with the did:web the tier fan-out resolves back to it. The
|
||||
// server serves its own DID document at /.well-known/did.json; handler gets
|
||||
// every other request. Resolving a loopback did:web needs a `-tags testmode`
|
||||
// build, which this file's build constraint guarantees.
|
||||
func managedHoldServer(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)
|
||||
}
|
||||
|
||||
// TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier runs the same
|
||||
// property through the real webhook path, so it covers the payload shape too:
|
||||
// Stripe sends price.product as a bare ID string, and this fails if that is
|
||||
// ever read wrongly.
|
||||
//
|
||||
// It asserts on the rank the managed hold is actually asked to apply. An
|
||||
// earlier draft asserted that the event was recorded as processed, and passed
|
||||
// against price-only resolution — because an unresolved tier ALSO records the
|
||||
// event and returns nil. That is the defect itself, so any assertion it
|
||||
// satisfies cannot be measuring the fix.
|
||||
func TestHandleSubscriptionChange_GrandfatheredSubscriberKeepsTier(t *testing.T) {
|
||||
const secret = "whsec_grandfather_test"
|
||||
m, _ := newTestManager(t, secret)
|
||||
m.cfg.Tiers = tierTestConfig().Tiers
|
||||
|
||||
stripeAPIReturning(t, http.StatusOK,
|
||||
`{"id":"cus_gf","object":"customer","metadata":{"user_did":"did:plc:grandfathered"}}`)
|
||||
|
||||
type tierPush struct {
|
||||
UserDID string `json:"userDid"`
|
||||
TierRank int `json:"tierRank"`
|
||||
}
|
||||
pushes := make(chan tierPush, 4)
|
||||
_, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var got tierPush
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
pushes <- got
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
m.managedHolds = []string{holdDID}
|
||||
|
||||
priv, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
m.privateKey = priv
|
||||
|
||||
// price_supporter_v1_retired appears nowhere in the config. Only its
|
||||
// product does — this is the subscriber a price change left behind.
|
||||
payload := fmt.Sprintf(`{
|
||||
"id": "evt_grandfathered",
|
||||
"object": "event",
|
||||
"api_version": %q,
|
||||
"type": "customer.subscription.updated",
|
||||
"created": %d,
|
||||
"data": {"object": {
|
||||
"id": "sub_gf",
|
||||
"object": "subscription",
|
||||
"status": "active",
|
||||
"customer": "cus_gf",
|
||||
"items": {"object":"list","data":[{"price":{
|
||||
"id":"price_supporter_v1_retired",
|
||||
"product":"prod_supporter"
|
||||
}}]}
|
||||
}}
|
||||
}`, stripe.APIVersion, time.Now().Unix())
|
||||
|
||||
if err := postWebhook(t, m, secret, []byte(payload)); err != nil {
|
||||
t.Fatalf("webhook: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-pushes:
|
||||
if got.TierRank != 1 {
|
||||
t.Errorf("hold was asked for tierRank %d, want 1 (supporter)", got.TierRank)
|
||||
}
|
||||
if got.UserDID != "did:plc:grandfathered" {
|
||||
t.Errorf("hold was asked to update %q, want did:plc:grandfathered", got.UserDID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no tier push reached the hold — the grandfathered subscriber's tier was never applied")
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
appdb "atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/stripe/stripe-go/v84"
|
||||
"github.com/stripe/stripe-go/v84/webhook"
|
||||
)
|
||||
@@ -287,46 +285,3 @@ func TestHandleSubscriptionChange_NilCustomerDoesNotPanic(t *testing.T) {
|
||||
t.Errorf("HandleWebhook error = %v, want nil for an event with no customer", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable closes the loop the
|
||||
// other tests in this file only cover one half of.
|
||||
//
|
||||
// The tier is resolved, the customer is known, and the only thing that fails is
|
||||
// the push to the managed hold. That has to reach Stripe as a 5xx and leave
|
||||
// stripe_processed_events empty: a hold that is briefly down otherwise costs
|
||||
// the customer their tier permanently, which is the same shape of loss as the
|
||||
// customer-lookup hole above, one layer further out.
|
||||
func TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable(t *testing.T) {
|
||||
atproto.SetTestMode(true)
|
||||
t.Cleanup(func() { atproto.SetTestMode(false) })
|
||||
|
||||
const secret = "whsec_fanout_test"
|
||||
m, database := newTestManager(t, secret)
|
||||
|
||||
// The customer resolves cleanly — this test is about what happens after.
|
||||
stripeAPIReturning(t, http.StatusOK,
|
||||
`{"id":"cus_fanout","object":"customer","metadata":{"user_did":"did:plc:fanoutuser"}}`)
|
||||
|
||||
_, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hold is down", http.StatusServiceUnavailable)
|
||||
})
|
||||
m.managedHolds = []string{holdDID}
|
||||
|
||||
priv, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
m.privateKey = priv
|
||||
|
||||
err = postWebhook(t, m, secret,
|
||||
signedSubscriptionEvent(t, secret, "evt_fanout_fail", "cus_fanout", time.Now().Unix()))
|
||||
if err == nil {
|
||||
t.Fatal("a hold that cannot be updated must fail the webhook so Stripe redelivers")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "push tier to managed holds") {
|
||||
t.Errorf("error does not identify the fan-out as the cause: %v", err)
|
||||
}
|
||||
if n := processedCount(t, database); n != 0 {
|
||||
t.Errorf("stripe_processed_events holds %d rows; a failed event must stay redeliverable", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build billing && testmode
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable closes the loop the
|
||||
// other tests in this file only cover one half of.
|
||||
//
|
||||
// The tier is resolved, the customer is known, and the only thing that fails is
|
||||
// the push to the managed hold. That has to reach Stripe as a 5xx and leave
|
||||
// stripe_processed_events empty: a hold that is briefly down otherwise costs
|
||||
// the customer their tier permanently, which is the same shape of loss as the
|
||||
// customer-lookup hole above, one layer further out.
|
||||
func TestHandleSubscriptionChange_HoldFanoutFailureIsRetryable(t *testing.T) {
|
||||
const secret = "whsec_fanout_test"
|
||||
m, database := newTestManager(t, secret)
|
||||
|
||||
// The customer resolves cleanly — this test is about what happens after.
|
||||
stripeAPIReturning(t, http.StatusOK,
|
||||
`{"id":"cus_fanout","object":"customer","metadata":{"user_did":"did:plc:fanoutuser"}}`)
|
||||
|
||||
_, holdDID := managedHoldServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hold is down", http.StatusServiceUnavailable)
|
||||
})
|
||||
m.managedHolds = []string{holdDID}
|
||||
|
||||
priv, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
m.privateKey = priv
|
||||
|
||||
err = postWebhook(t, m, secret,
|
||||
signedSubscriptionEvent(t, secret, "evt_fanout_fail", "cus_fanout", time.Now().Unix()))
|
||||
if err == nil {
|
||||
t.Fatal("a hold that cannot be updated must fail the webhook so Stripe redelivers")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "push tier to managed holds") {
|
||||
t.Errorf("error does not identify the fan-out as the cause: %v", err)
|
||||
}
|
||||
if n := processedCount(t, database); n != 0 {
|
||||
t.Errorf("stripe_processed_events holds %d rows; a failed event must stay redeliverable", n)
|
||||
}
|
||||
}
|
||||
@@ -160,9 +160,6 @@ type ServerConfig struct {
|
||||
// DID of successor hold for migration.
|
||||
Successor string `yaml:"successor" comment:"DID of successor hold for migration. Appview redirects all requests to the successor."`
|
||||
|
||||
// Use localhost for OAuth redirects during development.
|
||||
TestMode bool `yaml:"test_mode" comment:"Local development only. Skips relay crawl requests (a local hold is not reachable by public relays) and tolerates an appview token issuer that differs from appview_did. Does not affect DID resolution: that needs a -tags testmode build."`
|
||||
|
||||
// Relay endpoints used primarily for proactive scan discovery via
|
||||
// com.atproto.sync.listReposByCollection. Endpoints listed here MUST
|
||||
// support listReposByCollection. They are also sent requestCrawl on
|
||||
@@ -257,7 +254,6 @@ func setHoldDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.public_url", "")
|
||||
v.SetDefault("server.public", false)
|
||||
v.SetDefault("server.successor", "")
|
||||
v.SetDefault("server.test_mode", false)
|
||||
v.SetDefault("server.relay_endpoints", []string{
|
||||
"https://relay1.us-east.bsky.network",
|
||||
"https://relay1.us-west.bsky.network",
|
||||
|
||||
@@ -46,7 +46,6 @@ func TestLoadConfig_Success(t *testing.T) {
|
||||
"HOLD_SERVER_PUBLIC_URL": "https://hold.example.com",
|
||||
"HOLD_SERVER_ADDR": ":9000",
|
||||
"HOLD_SERVER_PUBLIC": "true",
|
||||
"HOLD_SERVER_TEST_MODE": "true",
|
||||
"HOLD_REGISTRATION_OWNER_DID": "did:plc:owner123",
|
||||
"HOLD_REGISTRATION_ALLOW_ALL_CREW": "true",
|
||||
"S3_BUCKET": "test-bucket",
|
||||
@@ -72,9 +71,6 @@ func TestLoadConfig_Success(t *testing.T) {
|
||||
if !cfg.Server.Public {
|
||||
t.Error("Expected Public=true")
|
||||
}
|
||||
if !cfg.Server.TestMode {
|
||||
t.Error("Expected TestMode=true")
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 5*time.Minute {
|
||||
t.Errorf("Expected ReadTimeout=5m, got %v", cfg.Server.ReadTimeout)
|
||||
}
|
||||
@@ -131,7 +127,6 @@ func TestLoadConfig_Defaults(t *testing.T) {
|
||||
// Don't set optional vars - test defaults
|
||||
"HOLD_SERVER_ADDR": "",
|
||||
"HOLD_SERVER_PUBLIC": "",
|
||||
"HOLD_SERVER_TEST_MODE": "",
|
||||
"HOLD_REGISTRATION_OWNER_DID": "",
|
||||
"HOLD_REGISTRATION_ALLOW_ALL_CREW": "",
|
||||
"AWS_REGION": "",
|
||||
@@ -151,9 +146,6 @@ func TestLoadConfig_Defaults(t *testing.T) {
|
||||
if cfg.Server.Public {
|
||||
t.Error("Expected default Public=false")
|
||||
}
|
||||
if cfg.Server.TestMode {
|
||||
t.Error("Expected default TestMode=false")
|
||||
}
|
||||
if cfg.Registration.OwnerDID != "" {
|
||||
t.Error("Expected default OwnerDID to be empty")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testmode
|
||||
|
||||
package pds
|
||||
|
||||
import (
|
||||
@@ -7,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/testpds"
|
||||
|
||||
@@ -41,12 +42,6 @@ func newAppviewTestEnvWithKey(t *testing.T, priv atcrypto.PrivateKey) *appviewTe
|
||||
t.Fatalf("public key: %v", err)
|
||||
}
|
||||
|
||||
// fetchAppviewPublicKey resolves the appview DID through the identity
|
||||
// directory; only a `-tags testmode` build resolves a loopback did:web.
|
||||
if !atproto.TestModeBuild {
|
||||
t.Fatal("this test resolves a did:web on 127.0.0.1 and needs a `-tags testmode` build")
|
||||
}
|
||||
|
||||
// The document mirrors what the real appview serves: the key is
|
||||
// controlled by the document's own DID, so the directory keeps it.
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -641,13 +641,14 @@ func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string,
|
||||
}
|
||||
|
||||
// Verify issuer matches configured appview DID.
|
||||
// In test mode the appview and hold often address each other under different
|
||||
// did:web hosts (browser-facing 127.0.0.1 vs docker bridge IP), so the label
|
||||
// won't match — signature verification below still uses the configured
|
||||
// appviewDID's public key, so a forged token from another signer would fail.
|
||||
// In a testmode build the appview and hold often address each other under
|
||||
// different did:web hosts (browser-facing 127.0.0.1 vs docker bridge IP), so
|
||||
// the label won't match — signature verification below still uses the
|
||||
// configured appviewDID's public key, so a forged token from another signer
|
||||
// would fail.
|
||||
if claims.Issuer != appviewDID {
|
||||
if atproto.IsTestMode() {
|
||||
slog.Warn("Appview token issuer mismatch tolerated in test mode",
|
||||
if atproto.TestModeBuild {
|
||||
slog.Warn("Appview token issuer mismatch tolerated in testmode build",
|
||||
"expected", appviewDID, "got", claims.Issuer)
|
||||
} else {
|
||||
return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build testmode
|
||||
|
||||
package pds
|
||||
|
||||
import (
|
||||
|
||||
+3
-7
@@ -104,10 +104,6 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
if cfg.Server.TestMode {
|
||||
atproto.SetTestMode(true)
|
||||
}
|
||||
|
||||
// Initialize embedded PDS if database path is configured
|
||||
var xrpcHandler *pds.XRPCHandler
|
||||
var s3Service *s3.S3Service
|
||||
@@ -412,11 +408,11 @@ func (s *HoldServer) ServeWithListener(listener net.Listener) error {
|
||||
// Request crawl from every known relay (plus any custom endpoint) so the
|
||||
// embedded PDS becomes discoverable. Without this, did:web holds are
|
||||
// invisible to relays — and to any appview that backfills via them.
|
||||
// Skipped in test_mode: local dev holds aren't reachable by public relays.
|
||||
if !s.Config.Server.TestMode {
|
||||
// Skipped in a testmode build: local dev holds aren't reachable by public relays.
|
||||
if !atproto.TestModeBuild {
|
||||
go s.requestCrawls()
|
||||
} else {
|
||||
slog.Info("Skipping relay crawl requests (test_mode enabled)")
|
||||
slog.Info("Skipping relay crawl requests (testmode build)")
|
||||
}
|
||||
|
||||
// Start garbage collector (runs on startup + nightly)
|
||||
|
||||
+1
-2
@@ -35,8 +35,7 @@ against `localhost:5000` measures the redirect, not the endpoint.
|
||||
`ui.sessions` (`pkg/hold/admin/admin.go`), not the `admin_sessions` table, which
|
||||
is vestigial for this path. Air rebuilds the hold whenever tracked source
|
||||
changes — including a batch checkout — so budget one interactive login per
|
||||
switch. There is no test-mode bypass; `server.test_mode` only affects OAuth
|
||||
redirect URLs.
|
||||
switch. There is no test-mode bypass.
|
||||
|
||||
**Never drive the admin panel with curl.** Sessions are pinned to User-Agent and
|
||||
client IP prefix, and a mismatch does not merely reject the request — it calls
|
||||
|
||||
Reference in New Issue
Block a user