package webhooks import ( "context" "net/http/httptest" "testing" "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" ) // scanForUser builds a minimal first-time scan record for dispatch tests. func scanForUser(userDID string) *db.Scan { return &db.Scan{ UserDID: userDID, HoldDID: "did:web:hold", Repository: "app", ManifestDigest: "sha256:deadbeef", ScannedAt: time.Now().UTC(), ScannerVersion: "test", } } // TestDispatchForScan_EntitlementGate verifies the dispatch-time backstop: // paid triggers are dropped and the webhook count is capped when the limiter // reports a non-entitled (e.g. self-hosted / downgraded) user. func TestDispatchForScan_EntitlementGate(t *testing.T) { conn, err := db.InitDB(":memory:", db.LibsqlConfig{}) if err != nil { t.Fatalf("init db: %v", err) } defer conn.Close() const userDID = "did:plc:scanent" if err := db.UpsertUser(conn, &db.User{ DID: userDID, Handle: "se.test", PDSEndpoint: "https://pds", LastSeen: time.Now(), }); err != nil { t.Fatalf("upsert user: %v", err) } receiver := newFakeReceiver(8) recvSrv := httptest.NewServer(receiver.handler()) defer recvSrv.Close() // Two webhooks, both with the paid TriggerAll set, created oldest-first. for i, id := range []string{"wh-old", "wh-new"} { hook := &db.Webhook{ ID: id, UserDID: userDID, URL: recvSrv.URL, Triggers: PackTriggers(TriggerFirst|TriggerAll, 0), CreatedAt: time.Now().UTC().Add(time.Duration(i) * time.Second), } if err := db.InsertWebhook(conn, hook); err != nil { t.Fatalf("insert hook %s: %v", id, err) } } meta := atproto.AppviewMetadata{ClientShortName: "ATCR", BaseURL: "https://atcr.test"} // Free tier: max 1 webhook, no paid triggers. The cap keeps only the oldest // webhook, and scan:all (paid) is masked out — leaving just scan:first. free := NewDispatcher(conn, meta, func(string) (int, bool) { return 1, false }) free.DispatchForScan(context.Background(), scanForUser(userDID), nil, "se.test", "latest", "https://hold") if !receiver.waitFor(1, 2*time.Second) { t.Fatalf("free tier: expected 1 delivery (scan:first on oldest hook), got %d", receiver.count()) } time.Sleep(150 * time.Millisecond) // allow any erroneous extra deliveries to land if got := receiver.count(); got != 1 { t.Fatalf("free tier: expected exactly 1 delivery, got %d", got) } // Entitled: unlimited + all triggers. Both webhooks fire, each delivering // scan:first AND scan:all = 4 deliveries (regression guard that the gate // doesn't over-suppress). entitled := NewDispatcher(conn, meta, func(string) (int, bool) { return -1, true }) entitled.DispatchForScan(context.Background(), scanForUser(userDID), nil, "se.test", "latest", "https://hold") if !receiver.waitFor(1+4, 2*time.Second) { t.Fatalf("entitled: expected 4 more deliveries (2 hooks x scan:first+scan:all), total got %d", receiver.count()) } }