owpengram-server/internal/store/postgres/verification_integration_test.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

967 lines
39 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// verificationTestUser inserts a throwaway user row and registers the cleanup for
// everything the verification tables may hang off it. Events reference the
// application with ON DELETE RESTRICT, so the timeline has to go first.
func verificationTestUser(t *testing.T, pool *pgxpool.Pool) int64 {
t.Helper()
ctx := context.Background()
suffix := randomSuffix(t)
var id int64
if err := pool.QueryRow(ctx, `
INSERT INTO users (access_hash, phone, first_name)
VALUES ($1, $2, 'verification test')
RETURNING id`, time.Now().UnixNano()&0x7fffffffffffffff, "9"+suffix).Scan(&id); err != nil {
t.Fatalf("insert verification test user: %v", err)
}
t.Cleanup(func() {
cleanupCtx := context.Background()
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_application_events
WHERE application_id IN (
SELECT id FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1
)`, id)
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_notification_outbox
WHERE application_id IN (
SELECT id FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1
)`, id)
_, _ = pool.Exec(cleanupCtx, `
DELETE FROM verification_applications
WHERE applicant_user_id = $1 OR target_id = $1`, id)
_, _ = pool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// verificationTestDraft is a payload that clears domain.ValidateForSubmission.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
func verificationUserVerified(t *testing.T, pool *pgxpool.Pool, userID int64) bool {
t.Helper()
var verified bool
if err := pool.QueryRow(context.Background(),
`SELECT verified FROM users WHERE id = $1`, userID).Scan(&verified); err != nil {
t.Fatalf("read verified flag: %v", err)
}
return verified
}
// verificationTxApply is the callback shape the app layer is expected to use: the
// peer flag is written through the transaction that is deciding the application,
// so the two writes commit or roll back together.
func verificationTxApply(_ *testing.T) func(context.Context, domain.VerificationApplication) error {
return func(ctx context.Context, app domain.VerificationApplication) error {
tx, ok := VerificationTxFromContext(ctx)
if !ok {
return fmt.Errorf("decision context carries no transaction")
}
if _, err := NewUserStore(tx).SetVerified(ctx, app.TargetID, true); err != nil {
return err
}
return nil
}
}
// TestVerificationDraftLifecyclePostgres covers the applicant path against the
// real schema: a draft is opened once and resumed on the next /start, the payload
// round-trips, and only a complete application reaches the queue.
func TestVerificationDraftLifecyclePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
req := verificationTestRequest(applicant, domain.VerificationTargetBot, target, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
resumed, created, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID {
t.Fatalf("resumed draft = %d, want %d", resumed.ID, app.ID)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 ||
saved.SocialLinks[0] != "https://t.me/example" {
t.Fatalf("saved draft = v%d social=%v press=%v", saved.Version, saved.SocialLinks, saved.PressLinks)
}
if !saved.UpdatedAt.Equal(saved.UpdatedAt.UTC()) || saved.UpdatedAt.Before(saved.CreatedAt) {
t.Fatalf("updated_at = %v, created_at = %v", saved.UpdatedAt, saved.CreatedAt)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version,
domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read: %v", err)
}
if reread.Description != saved.Description || reread.AdditionalNote != saved.AdditionalNote ||
reread.Category != "media" || reread.CorrelationID != fmt.Sprintf("corr-%d", target) {
t.Fatalf("payload did not round-trip: %+v", reread)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, applicant); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, target)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
}
// TestVerificationActiveTargetUniquenessPostgres is the partial unique index at
// work, including the cancelled-draft case the submitted_at CHECK constrains.
func TestVerificationActiveTargetUniquenessPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
first := verificationTestUser(t, pool)
second := verificationTestUser(t, pool)
third := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, first, domain.VerificationTargetChannel, target, "beta")
if _, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetChannel, target, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// The same numeric id in the user namespace is a different target.
namespaced, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetBot, target, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of tripping the applicant-draft unique index.
resumed, created, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(second, domain.VerificationTargetBot, third, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, app.ID, app.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" || cancelled.ReviewerAdminID != "" || !cancelled.ReviewedAt.IsZero() {
t.Fatalf("cancel wrote decision metadata: %+v", cancelled)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(third, domain.VerificationTargetChannel, target, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestVerificationCancelledDraftPostgres pins the one place the schema forces the
// store's hand: a draft that is withdrawn before submission still needs
// submitted_at, because "status <> 'draft'" requires it.
func TestVerificationCancelledDraftPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
draft, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(applicant, domain.VerificationTargetBot, target, "iota"))
if err != nil {
t.Fatalf("create draft: %v", err)
}
cancelled, err := s.CancelVerificationApplication(ctx, draft.ID, draft.Version, "never mind")
if err != nil {
t.Fatalf("cancel draft: %v", err)
}
if cancelled.SubmittedAt.IsZero() {
t.Fatal("cancelled draft has no submitted_at, which the CHECK forbids")
}
events, err := s.VerificationApplicationEvents(ctx, draft.ID, 10)
if err != nil || len(events) != 2 {
t.Fatalf("history = %d rows err=%v, want created + cancelled", len(events), err)
}
if events[0].Kind != domain.VerificationEventCancelled ||
events[0].FromStatus != domain.VerificationStatusDraft ||
events[0].ToStatus != domain.VerificationStatusCancelled ||
events[0].Reason != "never mind" {
t.Fatalf("cancelled event = %+v", events[0])
}
}
// TestVerificationApproveWritesPeerFlagPostgres is the core invariant: the peer
// flag and the decision share one transaction, so a failing callback leaves
// neither behind and a successful one cannot be observed without the other.
func TestVerificationApproveWritesPeerFlagPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview ||
claimed.ReviewerAdminID != "admin-a" || !claimed.ReviewedAt.IsZero() {
t.Fatalf("claimed = %+v", claimed)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// The callback sets the flag through the decision transaction and then fails,
// so the rollback has to take the flag with it.
failing := errors.New("notification pipeline unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(ctx context.Context, decided domain.VerificationApplication) error {
if err := verificationTxApply(t)(ctx, decided); err != nil {
return err
}
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("rolled-back approval left the target verified")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(verificationRowsFor(pending, app.ID)) != 0 {
t.Fatalf("outbox after failed approve = %+v, want empty", pending)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, verificationTxApply(t))
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 ||
approved.InternalNote != "checked the press coverage" {
t.Fatalf("approved = %+v", approved)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("approved application whose target is not verified")
}
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Error("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
mine := verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Kind != "approved" || mine[0].RecipientUserID != applicant {
t.Fatalf("outbox = %+v, want exactly one approved row for the applicant", mine)
}
if mine[0].Application.ID != app.ID || mine[0].Application.TargetUsername != "gamma" ||
mine[0].Application.Status != domain.VerificationStatusApproved {
t.Fatalf("outbox row carries no application context: %+v", mine[0].Application)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestVerificationRejectAndCooldownPostgres covers the rejection path and the
// cooldown lookup the re-application check is measured from.
func TestVerificationRejectAndCooldownPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
other := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" ||
rejected.ReviewedAt.IsZero() {
t.Fatalf("rejected = %+v", rejected)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("rejection verified the target")
}
cooldown, err := s.LastVerificationRejection(ctx, applicant, domain.VerificationTargetChannel, target)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, other, domain.VerificationTargetChannel, target); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
second := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetChannel, target, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, applicant, domain.VerificationTargetChannel, target)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
history, err := s.VerificationApplicationsForApplicant(ctx, applicant, 10)
if err != nil || len(history) != 2 || history[0].ID != second.ID || history[1].ID != app.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
}
// TestVerificationConcurrentDecisionPostgres runs two reviewers at the same time
// on the same version. Exactly one decision, exactly one notification, and the
// loser is told it lost instead of silently overwriting the winner.
func TestVerificationConcurrentDecisionPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "epsilon")
var mu sync.Mutex
calls := 0
apply := func(ctx context.Context, decided domain.VerificationApplication) error {
mu.Lock()
calls++
mu.Unlock()
return verificationTxApply(t)(ctx, decided)
}
results := make([]error, 2)
var wg sync.WaitGroup
for i, reviewer := range []string{"admin-a", "admin-b"} {
wg.Add(1)
go func(i int, reviewer string) {
defer wg.Done()
_, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: reviewer,
}, true, apply)
results[i] = err
}(i, reviewer)
}
wg.Wait()
winners, conflicts := 0, 0
for _, err := range results {
switch {
case err == nil:
winners++
case errors.Is(err, domain.ErrVerificationVersionConflict):
conflicts++
default:
t.Fatalf("unexpected concurrent decision error: %v", err)
}
}
if winners != 1 || conflicts != 1 {
t.Fatalf("concurrent decision = %d winners, %d conflicts, want 1 and 1", winners, conflicts)
}
if calls != 1 {
t.Fatalf("applyVerified ran %d times, want exactly 1", calls)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.Status != domain.VerificationStatusApproved || final.Version != app.Version+1 {
t.Fatalf("final application = %s v%d err=%v", final.Status, final.Version, err)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("approved application whose target is not verified")
}
pending, err := s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
if got := verificationRowsFor(pending, app.ID); len(got) != 1 {
t.Fatalf("outbox = %+v, want exactly one notification", got)
}
var events int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND kind = 'approved'`, app.ID).Scan(&events); err != nil {
t.Fatalf("count approved events: %v", err)
}
if events != 1 {
t.Fatalf("approved history rows = %d, want 1", events)
}
}
// TestVerificationRevokePostgres takes the badge back: the flag is cleared in the
// same transaction, the application stays approved as history, and the revocation
// notifies exactly once.
func TestVerificationRevokePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
orphanTarget := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, verificationTxApply(t))
if err != nil {
t.Fatalf("approve: %v", err)
}
clear := func(ctx context.Context, peer domain.Peer) error {
tx, ok := VerificationTxFromContext(ctx)
if !ok {
return fmt.Errorf("revocation context carries no transaction")
}
if peer.Type != domain.PeerTypeUser {
return fmt.Errorf("unexpected peer type %q", peer.Type)
}
_, err := NewUserStore(tx).SetVerified(ctx, peer.ID, false)
return err
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: target,
Reviewer: "admin-b", Reason: "impersonation report upheld", CorrelationID: "cmd-r",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: target, Reviewer: "admin-b",
}, clear); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(ctx context.Context, peer domain.Peer) error {
if err := clear(ctx, peer); err != nil {
return err
}
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
if !verificationUserVerified(t, pool, target) {
t.Fatal("rolled-back revocation cleared the flag anyway")
}
revoked, changed, err := s.RevokeVerification(ctx, req, clear)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
if verificationUserVerified(t, pool, target) {
t.Fatal("revocation left the target verified")
}
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Error("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
var revokedEvents, revokedOutbox int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedEvents); err != nil {
t.Fatalf("count revoked events: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_notification_outbox
WHERE application_id = $1 AND kind = 'revoked'`, app.ID).Scan(&revokedOutbox); err != nil {
t.Fatalf("count revoked outbox rows: %v", err)
}
if revokedEvents != 1 || revokedOutbox != 1 {
t.Fatalf("revocation recorded %d events and %d outbox rows, want 1 and 1", revokedEvents, revokedOutbox)
}
// A flag with no application behind it is still cleared: leaving it standing
// is worse than a missing audit row.
if _, err := NewUserStore(pool).SetVerified(ctx, orphanTarget, true); err != nil {
t.Fatalf("seed orphan flag: %v", err)
}
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: orphanTarget,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, clear)
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
if verificationUserVerified(t, pool, orphanTarget) {
t.Fatal("orphan revocation left the flag standing")
}
}
// TestVerificationHistoryAndOutboxPostgres pins the append-only timeline and
// walks one notification from pending to delivered.
func TestVerificationHistoryAndOutboxPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
applicant := verificationTestUser(t, pool)
target := verificationTestUser(t, pool)
app := submittedVerificationApplication(t, s, applicant, domain.VerificationTargetBot, target, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, verificationTxApply(t)); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending: %v", err)
}
mine := verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Attempts != 0 {
t.Fatalf("pending = %+v, want one fresh row", mine)
}
id := mine[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending after failure: %v", err)
}
mine = verificationRowsFor(pending, app.ID)
if len(mine) != 1 || mine[0].Attempts != 1 {
t.Fatalf("after failure = %+v, want still pending with one attempt", mine)
}
var lastError string
if err := pool.QueryRow(ctx, `
SELECT last_error FROM verification_notification_outbox WHERE id = $1`, id).Scan(&lastError); err != nil {
t.Fatalf("read last_error: %v", err)
}
if lastError != "bot blocked by user" {
t.Fatalf("last_error = %q", lastError)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 100)
if err != nil {
t.Fatalf("pending after delivery: %v", err)
}
if got := verificationRowsFor(pending, app.ID); len(got) != 0 {
t.Fatalf("delivered row is still pending: %+v", got)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, 0); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("deliver id=0 err = %v, want ErrVerificationApplicationInvalid", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d: %+v", len(events), len(wantKinds), events)
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d", i)
}
}
if events[1].FromStatus != domain.VerificationStatusInReview ||
events[1].ToStatus != domain.VerificationStatusApproved ||
events[1].Actor != "admin-a" || events[1].CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", events[1])
}
if events[0].Reason != "approved" {
t.Fatalf("notified event = %+v, want the approved notification", events[0])
}
// The timeline is append-only: nothing in the store rewrites a row, and the
// delivered notification did not touch the earlier ones.
var mutated int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM verification_application_events
WHERE application_id = $1 AND created_at < (
SELECT created_at FROM verification_application_events
WHERE application_id = $1 ORDER BY id LIMIT 1
)`, app.ID).Scan(&mutated); err != nil {
t.Fatalf("check event ordering: %v", err)
}
if mutated != 0 {
t.Fatalf("%d history rows predate the first one", mutated)
}
}
// TestVerificationQueueQueriesPostgres covers the review-queue projection against
// the real indexes: status and target filters, reviewer scoping, the search shapes
// and keyset paging. Every assertion is scoped by the date lower bound so rows
// from other runs cannot leak into it.
func TestVerificationQueueQueriesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
suffix := randomSuffix(t)
start := time.Now().UTC().Truncate(time.Microsecond)
before, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts before: %v", err)
}
firstApplicant := verificationTestUser(t, pool)
secondApplicant := verificationTestUser(t, pool)
thirdApplicant := verificationTestUser(t, pool)
firstTarget := verificationTestUser(t, pool)
secondTarget := verificationTestUser(t, pool)
thirdTarget := verificationTestUser(t, pool)
reviewer := "admin-" + suffix
first := submittedVerificationApplication(t, s, firstApplicant, domain.VerificationTargetBot, firstTarget, "alpha"+suffix)
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: reviewer,
}); err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, secondApplicant, domain.VerificationTargetChannel, secondTarget, "Beta"+suffix)
third, _, err := s.CreateVerificationDraft(ctx,
verificationTestRequest(thirdApplicant, domain.VerificationTargetSupergroup, thirdTarget, "gamma"+suffix))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
list := func(filter domain.VerificationApplicationFilter) []int64 {
t.Helper()
if filter.CreatedAt.IsZero() {
filter.CreatedAt = start
}
got, err := s.ListVerificationApplications(ctx, filter)
if err != nil {
t.Fatalf("list: %v", err)
}
return ids(got)
}
if got := list(domain.VerificationApplicationFilter{}); !equal(got, third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", got)
}
if got := list(domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
}); !equal(got, second.ID, first.ID) {
t.Fatalf("status filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
}); !equal(got, second.ID) {
t.Fatalf("target type filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Reviewer: reviewer}); !equal(got, first.ID) {
t.Fatalf("reviewer filter = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Reviewer: "admin-nobody"}); len(got) != 0 {
t.Fatalf("unknown reviewer = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(first.ID)}); !equal(got, first.ID) {
t.Fatalf("application id search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: fmt.Sprint(secondTarget)}); !equal(got, second.ID) {
t.Fatalf("peer id search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "@beta" + suffix}); !equal(got, second.ID) {
t.Fatalf("username search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "ALPHA" + suffix}); !equal(got, first.ID) {
t.Fatalf("case-insensitive username search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{Query: "nobody" + suffix}); len(got) != 0 {
t.Fatalf("miss search = %v", got)
}
if got := list(domain.VerificationApplicationFilter{CreatedAt: second.CreatedAt}); !equal(got, third.ID, second.ID) {
t.Fatalf("since filter = %v", got)
}
// BeforeID without a cursor timestamp still bounds the page.
if got := list(domain.VerificationApplicationFilter{BeforeID: second.ID}); !equal(got, first.ID) {
t.Fatalf("id-only cursor = %v", got)
}
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2,
})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: start, Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
after, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts after: %v", err)
}
for status, want := range map[domain.VerificationStatus]int64{
domain.VerificationStatusDraft: 1,
domain.VerificationStatusSubmitted: 1,
domain.VerificationStatusInReview: 1,
} {
if got := after[status] - before[status]; got != want {
t.Fatalf("count delta for %s = %d, want %d", status, got, want)
}
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestVerificationMissingApplicationPostgres pins the not-found surface every
// mutation shares.
func TestVerificationMissingApplicationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewVerificationStore(pool)
var missing int64
if err := pool.QueryRow(ctx,
`SELECT COALESCE(max(id), 0) + 1000 FROM verification_applications`).Scan(&missing); err != nil {
t.Fatalf("pick missing id: %v", err)
}
if _, err := s.VerificationApplication(ctx, missing); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, missing, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: missing, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: missing, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
if _, err := s.VerificationApplicationEvents(ctx, missing, 10); err != nil {
t.Fatalf("events of a missing application: %v", err)
}
}
// verificationRowsFor narrows the shared outbox to one application, so a test
// never depends on what other rows the test database happens to hold.
func verificationRowsFor(rows []store.VerificationNotification, applicationID int64) []store.VerificationNotification {
out := make([]store.VerificationNotification, 0, len(rows))
for _, row := range rows {
if row.ApplicationID == applicationID {
out = append(out, row)
}
}
return out
}