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>
760 lines
33 KiB
Go
760 lines
33 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// verificationTestDraft is a payload that clears domain.ValidateForSubmission:
|
|
// a category, a long enough description, a website and two independent press
|
|
// links.
|
|
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
|
|
}
|
|
|
|
// TestMemoryVerificationDraftLifecycle covers the applicant path: a draft is
|
|
// opened once and resumed on the next /start, the payload is stored verbatim, and
|
|
// submission stamps the queue entry.
|
|
func TestMemoryVerificationDraftLifecycle(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
|
|
req := verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "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.TargetUsername != "AlphaBot" || app.CorrelationID != "corr-5001" {
|
|
t.Fatalf("draft snapshot = %q / %q", app.TargetUsername, app.CorrelationID)
|
|
}
|
|
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
|
|
t.Fatal("fresh draft carries review metadata")
|
|
}
|
|
|
|
// The bot dialog is one conversation: a second /start resumes the draft.
|
|
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot"))
|
|
if err != nil || created {
|
|
t.Fatalf("resume draft: created=%v err=%v", created, err)
|
|
}
|
|
if resumed.ID != app.ID || resumed.Version != app.Version {
|
|
t.Fatalf("resumed draft = %d v%d, want %d v%d", resumed.ID, resumed.Version, app.ID, app.Version)
|
|
}
|
|
|
|
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.OfficialWebsite != "https://example.com" {
|
|
t.Fatalf("saved draft = v%d links=%v site=%q", saved.Version, saved.PressLinks, saved.OfficialWebsite)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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, 1001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
|
|
}
|
|
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, 5001)
|
|
if err != nil || active.ID != app.ID {
|
|
t.Fatalf("active for target = %d err=%v", active.ID, err)
|
|
}
|
|
|
|
// Returned slices are copies: mutating them must not reach the store.
|
|
submitted.PressLinks[0] = "https://evil.example.com"
|
|
reread, err := s.VerificationApplication(ctx, app.ID)
|
|
if err != nil || reread.PressLinks[0] != "https://press.example.com/story" {
|
|
t.Fatalf("stored press links mutated through the caller: %v err=%v", reread.PressLinks, err)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationActiveTargetUniqueness is the partial unique index: one
|
|
// live application per target, and a decided one no longer blocks a fresh
|
|
// attempt.
|
|
func TestMemoryVerificationActiveTargetUniqueness(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 6001, "beta")
|
|
|
|
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetChannel, 6001, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
|
|
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
|
|
}
|
|
// A different target is free for the same target id in another namespace.
|
|
namespaced, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6001, "betabot"))
|
|
if err != nil {
|
|
t.Fatalf("other namespace draft: %v", err)
|
|
}
|
|
// One draft per applicant: naming another target resumes the same
|
|
// conversation instead of opening a second draft.
|
|
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6002, "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, first.ID, first.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 != "" {
|
|
t.Fatalf("cancel wrote the applicant reason into decision_reason: %q", cancelled.DecisionReason)
|
|
}
|
|
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(1003, domain.VerificationTargetChannel, 6001, "beta")); err != nil {
|
|
t.Fatalf("draft after cancellation: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationClaimAndApprove is the reviewer path, including the
|
|
// invariant that matters most: the peer flag is written by the callback and the
|
|
// application is only approved if that callback succeeded.
|
|
func TestMemoryVerificationClaimAndApprove(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 7001, "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" {
|
|
t.Fatalf("claimed = %s by %q", claimed.Status, claimed.ReviewerAdminID)
|
|
}
|
|
if !claimed.ReviewedAt.IsZero() {
|
|
t.Fatal("claim stamped reviewed_at, which the schema pairs with a decision")
|
|
}
|
|
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)
|
|
}
|
|
|
|
// A callback failure must roll the whole decision back.
|
|
failing := errors.New("peer store unavailable")
|
|
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
|
|
}, true, func(context.Context, domain.VerificationApplication) error {
|
|
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 !rolled.ReviewedAt.IsZero() || rolled.DecisionReason != "" {
|
|
t.Fatal("failed approve wrote decision metadata")
|
|
}
|
|
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 0 {
|
|
t.Fatalf("outbox after failed approve = %d rows err=%v", len(pending), err)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
verified := make(map[domain.Peer]bool)
|
|
applyVerified := func(_ context.Context, decided domain.VerificationApplication) error {
|
|
if decided.Status != domain.VerificationStatusApproved {
|
|
return fmt.Errorf("callback saw %s, want approved", decided.Status)
|
|
}
|
|
verified[decided.Target()] = true
|
|
return nil
|
|
}
|
|
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, applyVerified)
|
|
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 {
|
|
t.Fatalf("approved = %s v%d by %q at %v", approved.Status, approved.Version,
|
|
approved.ReviewerAdminID, approved.ReviewedAt)
|
|
}
|
|
if !verified[approved.Target()] {
|
|
t.Fatal("approved application whose target is not verified")
|
|
}
|
|
|
|
// Re-issuing the decision must not notify twice.
|
|
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.Fatal("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, 10)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
if len(pending) != 1 || pending[0].Kind != "approved" || pending[0].RecipientUserID != 1001 {
|
|
t.Fatalf("outbox = %+v, want one approved row for the applicant", pending)
|
|
}
|
|
if pending[0].Application.ID != app.ID || pending[0].Application.TargetUsername != "gamma" {
|
|
t.Fatalf("outbox row carries no application context: %+v", pending[0].Application)
|
|
}
|
|
|
|
// A decided application is terminal.
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationRejectRequiresReason keeps the audit trail honest: a
|
|
// rejection the applicant is told about always states why.
|
|
func TestMemoryVerificationRejectRequiresReason(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "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 == "" {
|
|
t.Fatalf("rejected = %s reason=%q", rejected.Status, rejected.DecisionReason)
|
|
}
|
|
|
|
cooldown, err := s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
|
|
if err != nil || cooldown.ID != app.ID {
|
|
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
|
|
}
|
|
if _, err := s.LastVerificationRejection(ctx, 1002, domain.VerificationTargetChannel, 8001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
|
|
}
|
|
|
|
// The rejected application is history, so the target is free again, and the
|
|
// newest rejection is the one the cooldown is measured from.
|
|
second := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "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, 1001, domain.VerificationTargetChannel, 8001)
|
|
if err != nil || cooldown.ID != second.ID {
|
|
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationConcurrentDecision is the two-reviewers case: both read
|
|
// the same version, exactly one decision lands and the loser is told.
|
|
func TestMemoryVerificationConcurrentDecision(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9001, "epsilon")
|
|
|
|
calls := 0
|
|
applyVerified := func(context.Context, domain.VerificationApplication) error {
|
|
calls++
|
|
return nil
|
|
}
|
|
if _, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
|
|
}, true, applyVerified); err != nil || !changed {
|
|
t.Fatalf("first approve: changed=%v err=%v", changed, err)
|
|
}
|
|
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b",
|
|
}, true, applyVerified); !errors.Is(err, domain.ErrVerificationVersionConflict) {
|
|
t.Fatalf("second approve err = %v, want ErrVerificationVersionConflict", err)
|
|
}
|
|
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b", Reason: "no",
|
|
}, false, nil); !errors.Is(err, domain.ErrVerificationVersionConflict) {
|
|
t.Fatalf("losing reject err = %v, want ErrVerificationVersionConflict", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("applyVerified called %d times, want exactly 1", calls)
|
|
}
|
|
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 1 {
|
|
t.Fatalf("outbox = %d rows err=%v, want exactly one notification", len(pending), err)
|
|
}
|
|
final, err := s.VerificationApplication(ctx, app.ID)
|
|
if err != nil || final.ReviewerAdminID != "admin-a" {
|
|
t.Fatalf("final decision by %q err=%v, want admin-a", final.ReviewerAdminID, err)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationRevoke covers taking the badge back: the flag is cleared
|
|
// through the callback, the application stays approved as history and the
|
|
// revocation notifies exactly once.
|
|
func TestMemoryVerificationRevoke(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 9101, "zeta")
|
|
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
|
|
}, true, func(context.Context, domain.VerificationApplication) error { return nil })
|
|
if err != nil {
|
|
t.Fatalf("approve: %v", err)
|
|
}
|
|
|
|
req := domain.VerificationRevocation{
|
|
TargetType: domain.VerificationTargetChannel, TargetID: 9101,
|
|
Reviewer: "admin-b", Reason: "impersonation report upheld",
|
|
}
|
|
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
|
|
TargetType: domain.VerificationTargetChannel, TargetID: 9101, Reviewer: "admin-b",
|
|
}, func(context.Context, domain.Peer) error { return nil }); !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(context.Context, domain.Peer) error {
|
|
return failing
|
|
}); !errors.Is(err, failing) {
|
|
t.Fatalf("failing revoke err = %v, want %v", err, failing)
|
|
}
|
|
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
|
|
if err != nil {
|
|
t.Fatalf("events: %v", err)
|
|
}
|
|
for _, event := range events {
|
|
if event.Kind == domain.VerificationEventRevoked {
|
|
t.Fatal("failed revoke appended a revoked event")
|
|
}
|
|
}
|
|
|
|
var cleared []domain.Peer
|
|
revoked, changed, err := s.RevokeVerification(ctx, req, func(_ context.Context, target domain.Peer) error {
|
|
cleared = append(cleared, target)
|
|
return nil
|
|
})
|
|
if err != nil || !changed {
|
|
t.Fatalf("revoke: changed=%v err=%v", changed, err)
|
|
}
|
|
if len(cleared) != 1 || cleared[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 9101}) {
|
|
t.Fatalf("cleared = %v, want the channel peer", cleared)
|
|
}
|
|
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
|
|
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
|
|
}
|
|
|
|
// A second revocation is a no-op: one outbox row, one history entry.
|
|
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
|
|
t.Fatal("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)
|
|
}
|
|
|
|
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("pending: %v", err)
|
|
}
|
|
kinds := make([]string, 0, len(pending))
|
|
for _, item := range pending {
|
|
kinds = append(kinds, item.Kind)
|
|
}
|
|
if len(kinds) != 2 || kinds[0] != "approved" || kinds[1] != "revoked" {
|
|
t.Fatalf("outbox kinds = %v, want [approved revoked] in that order", kinds)
|
|
}
|
|
|
|
// A target nobody ever applied for is still cleared: a standing flag is worse
|
|
// than a missing audit row.
|
|
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
|
|
TargetType: domain.VerificationTargetBot, TargetID: 9999,
|
|
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
|
|
}, func(context.Context, domain.Peer) error { return nil })
|
|
if err != nil || !changed || orphan.ID != 0 {
|
|
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationHistoryOrder pins the append-only timeline: newest first,
|
|
// with the from/to statuses of every transition.
|
|
func TestMemoryVerificationHistoryOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9201, "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, func(context.Context, domain.VerificationApplication) error { return nil }); err != nil {
|
|
t.Fatalf("approve: %v", err)
|
|
}
|
|
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 1 {
|
|
t.Fatalf("pending = %d err=%v", len(pending), err)
|
|
}
|
|
if err := s.MarkVerificationNotificationDelivered(ctx, pending[0].ID); err != nil {
|
|
t.Fatalf("deliver: %v", 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", len(events), len(wantKinds))
|
|
}
|
|
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: %d >= %d", i, events[i].ID, events[i-1].ID)
|
|
}
|
|
}
|
|
approvedEvent := events[1]
|
|
if approvedEvent.FromStatus != domain.VerificationStatusInReview ||
|
|
approvedEvent.ToStatus != domain.VerificationStatusApproved ||
|
|
approvedEvent.Actor != "admin-a" || approvedEvent.CorrelationID != "cmd-9" {
|
|
t.Fatalf("approved event = %+v", approvedEvent)
|
|
}
|
|
if events[3].FromStatus != domain.VerificationStatusDraft ||
|
|
events[3].ToStatus != domain.VerificationStatusSubmitted {
|
|
t.Fatalf("submitted event = %+v", events[3])
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationOutboxDelivery walks one notification from pending to
|
|
// delivered, including the failure trace a poisoned row leaves behind.
|
|
func TestMemoryVerificationOutboxDelivery(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9301, "theta")
|
|
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a", Reason: "no press",
|
|
}, false, nil); err != nil {
|
|
t.Fatalf("reject: %v", err)
|
|
}
|
|
pending, err := s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 1 || pending[0].Attempts != 0 {
|
|
t.Fatalf("pending = %+v err=%v", pending, err)
|
|
}
|
|
id := pending[0].ID
|
|
|
|
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
|
|
t.Fatalf("fail: %v", err)
|
|
}
|
|
pending, err = s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 1 || pending[0].Attempts != 1 {
|
|
t.Fatalf("after failure = %+v err=%v, want still pending with 1 attempt", pending, err)
|
|
}
|
|
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
|
|
t.Fatalf("deliver: %v", err)
|
|
}
|
|
pending, err = s.PendingVerificationNotifications(ctx, 10)
|
|
if err != nil || len(pending) != 0 {
|
|
t.Fatalf("after delivery = %d rows err=%v, want none", len(pending), err)
|
|
}
|
|
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, id+1000); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("deliver unknown id err = %v, want ErrVerificationApplicationNotFound", err)
|
|
}
|
|
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
|
|
if err != nil {
|
|
t.Fatalf("events: %v", err)
|
|
}
|
|
if events[0].Kind != domain.VerificationEventNotified || events[0].Reason != "rejected" {
|
|
t.Fatalf("notified event = %+v, want the rejected notification", events[0])
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationQueueQueries covers the review-queue projection: status
|
|
// and target filters, reviewer scoping, the three search shapes and keyset paging.
|
|
func TestMemoryVerificationQueueQueries(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
|
|
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9401, "AlphaBot")
|
|
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: first.ID, Version: first.Version, Reviewer: "admin-a",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("claim: %v", err)
|
|
}
|
|
second := submittedVerificationApplication(t, s, 1002, domain.VerificationTargetChannel, 9402, "BetaChannel")
|
|
third, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetSupergroup, 9403, "gammagroup"))
|
|
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
|
|
}
|
|
|
|
all, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{})
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if !equal(ids(all), third.ID, second.ID, first.ID) {
|
|
t.Fatalf("queue order = %v, want newest first", ids(all))
|
|
}
|
|
|
|
got, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
|
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
|
|
})
|
|
if err != nil || !equal(ids(got), second.ID, first.ID) {
|
|
t.Fatalf("status filter = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
|
TargetType: domain.VerificationTargetChannel,
|
|
})
|
|
if err != nil || !equal(ids(got), second.ID) {
|
|
t.Fatalf("target type filter = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-a"})
|
|
if err != nil || !equal(ids(got), first.ID) {
|
|
t.Fatalf("reviewer filter = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-z"})
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("unknown reviewer = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
|
CreatedAt: second.CreatedAt,
|
|
})
|
|
if err != nil || !equal(ids(got), third.ID, second.ID) {
|
|
t.Fatalf("since filter = %v err=%v", ids(got), err)
|
|
}
|
|
// BeforeID without a cursor timestamp still bounds the page.
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
|
BeforeID: second.ID,
|
|
})
|
|
if err != nil || !equal(ids(got), first.ID) {
|
|
t.Fatalf("id-only cursor = %v err=%v", ids(got), err)
|
|
}
|
|
|
|
// Search: application id, peer id, username prefix (with and without @).
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
|
|
Query: fmt.Sprint(first.ID),
|
|
})
|
|
if err != nil || !equal(ids(got), first.ID) {
|
|
t.Fatalf("id search = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "9402"})
|
|
if err != nil || !equal(ids(got), second.ID) {
|
|
t.Fatalf("peer id search = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "@beta"})
|
|
if err != nil || !equal(ids(got), second.ID) {
|
|
t.Fatalf("username search = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "ALPHA"})
|
|
if err != nil || !equal(ids(got), first.ID) {
|
|
t.Fatalf("case-insensitive username search = %v err=%v", ids(got), err)
|
|
}
|
|
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "nobody"})
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("miss search = %v err=%v", ids(got), err)
|
|
}
|
|
|
|
// Keyset paging over (created_at DESC, id DESC).
|
|
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{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{
|
|
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{
|
|
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)
|
|
}
|
|
|
|
counts, err := s.VerificationStatusCounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("counts: %v", err)
|
|
}
|
|
if counts[domain.VerificationStatusInReview] != 1 ||
|
|
counts[domain.VerificationStatusSubmitted] != 1 ||
|
|
counts[domain.VerificationStatusDraft] != 1 ||
|
|
counts[domain.VerificationStatusApproved] != 0 {
|
|
t.Fatalf("counts = %v", counts)
|
|
}
|
|
|
|
mine, err := s.VerificationApplicationsForApplicant(ctx, 1001, 10)
|
|
if err != nil || !equal(ids(mine), first.ID) {
|
|
t.Fatalf("applicant history = %v err=%v", ids(mine), err)
|
|
}
|
|
_ = claimed
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestMemoryVerificationMissingApplication pins the not-found surface every
|
|
// mutation shares.
|
|
func TestMemoryVerificationMissingApplication(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := NewVerificationStore()
|
|
if _, err := s.VerificationApplication(ctx, 42); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("read err = %v", err)
|
|
}
|
|
if _, err := s.SubmitVerificationApplication(ctx, 42, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("submit err = %v", err)
|
|
}
|
|
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
|
|
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
t.Fatalf("claim err = %v", err)
|
|
}
|
|
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
|
|
ApplicationID: 42, 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)
|
|
}
|
|
}
|