package moderation import ( "context" "errors" "testing" "time" "telesrv/internal/domain" "telesrv/internal/store/memory" ) func TestAppealLinkSubmissionIsHashOnlyIdempotentAndExpires(t *testing.T) { ctx := context.Background() now := time.Unix(1_750_000_000, 0).UTC() store := memory.NewModerationReportStore() service := NewService(store) target := domain.Peer{Type: domain.PeerTypeUser, ID: 202} if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{ ReporterUserID: 101, Source: domain.ModerationSourceAccountPeer, Target: target, Reason: domain.ModerationReasonFake, Option: "fake", Items: []domain.ModerationReportItem{{ Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID, AuthorUserID: target.ID, EvidenceSchemaVersion: 1, Evidence: []byte(`{"schema_version":1}`), }}, CreatedAt: now, }); err != nil { t.Fatal(err) } cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{Limit: 10}) if err != nil || len(cases) != 1 { t.Fatalf("cases=%+v err=%v", cases, err) } claimed, err := service.ClaimCase( ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second), ) if err != nil { t.Fatal(err) } detail, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{ CaseID: claimed.ID, ExpectedVersion: claimed.Version, Actor: "reviewer", Reason: "confirmed", CommandID: "appeal-link-decision", Kind: domain.ModerationDecisionViolation, Actions: []domain.ModerationActionDraft{{ Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`), }}, CreatedAt: now.Add(2 * time.Second), }) if err != nil { t.Fatal(err) } token, err := service.IssueAppealLink( ctx, claimed.ID, target.ID, now.Add(24*time.Hour), now.Add(3*time.Second), ) if err != nil { t.Fatal(err) } if len(token) != 43 { t.Fatalf("token length=%d", len(token)) } link, found, err := service.ResolveAppealLink(ctx, token, now.Add(4*time.Second)) if err != nil || !found || link.TokenHash == ([32]byte{}) { t.Fatalf("link=%+v found=%v err=%v", link, found, err) } expiredToken, err := service.IssueAppealLink( ctx, claimed.ID, target.ID, now.Add(10*time.Second), now.Add(4*time.Second), ) if err != nil { t.Fatal(err) } for i := 0; i < domain.MaxModerationAppealLinksPerCase-2; i++ { issuedAt := now.Add(time.Duration(20+i) * time.Second) if _, err := service.IssueAppealLink( ctx, claimed.ID, target.ID, now.Add(time.Hour), issuedAt, ); err != nil { t.Fatalf("issue bounded link %d: %v", i, err) } } if _, err := service.IssueAppealLink( ctx, claimed.ID, target.ID, now.Add(time.Hour), now.Add(time.Minute), ); !errors.Is(err, domain.ErrModerationActionConflict) { t.Fatalf("appeal link overflow err=%v", err) } if actions, err := store.ClaimModerationActions( ctx, now.Add(5*time.Second), 10, time.Minute, ); err != nil || len(actions) != 1 { t.Fatalf("actions=%+v err=%v", actions, err) } else if err := store.CompleteModerationAction( ctx, actions[0].ID, actions[0].Attempts, true, "", time.Time{}, now.Add(6*time.Second), ); err != nil { t.Fatal(err) } appeal, created, err := service.SubmitAppealLink( ctx, token, "The account was impersonated.", now.Add(7*time.Second), ) if err != nil || !created || appeal.CaseID != claimed.ID || appeal.AppellantUserID != target.ID { t.Fatalf("appeal=%+v created=%v err=%v", appeal, created, err) } retry, created, err := service.SubmitAppealLink( ctx, token, "A different retry body must not create another appeal.", now.Add(8*time.Second), ) if err != nil || created || retry.ID != appeal.ID || retry.Text != appeal.Text { t.Fatalf("retry=%+v created=%v err=%v", retry, created, err) } appealed, found, err := service.Case(ctx, detail.Case.ID) if err != nil || !found || appealed.Case.Status != domain.ModerationCaseAppealReview || len(appealed.Appeals) != 1 { t.Fatalf("appealed=%+v found=%v err=%v", appealed, found, err) } appealClaim, err := service.ClaimCase( ctx, appealed.Case.ID, appealed.Case.Version, "appeal-reviewer", now.Add(8*time.Second), ) if err != nil { t.Fatal(err) } grant := domain.ModerationDecisionRequest{ CaseID: appealClaim.ID, AppealID: appeal.ID, ExpectedVersion: appealClaim.Version, Actor: "appeal-reviewer", Reason: "original evidence was insufficient", CommandID: "appeal-grant-without-remedy", Kind: domain.ModerationDecisionAppealGrant, CreatedAt: now.Add(9 * time.Second), } if _, _, err := service.ReviewAppeal(ctx, grant); !errors.Is( err, domain.ErrModerationActionInvalid, ) { t.Fatalf("grant without required flag remedy err=%v", err) } grant.CommandID = "appeal-grant-with-remedy" grant.Actions = []domain.ModerationActionDraft{{ Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`), }} granted, created, err := service.ReviewAppeal(ctx, grant) if err != nil || !created || granted.Case.Status != domain.ModerationCaseActionPending { t.Fatalf("granted=%+v created=%v err=%v", granted, created, err) } remedies, err := store.ClaimModerationActions( ctx, now.Add(10*time.Second), 10, time.Minute, ) if err != nil || len(remedies) != 1 || remedies[0].Kind != domain.ModerationActionClearPeerFlags { t.Fatalf("remedies=%+v err=%v", remedies, err) } if err := store.CompleteModerationAction( ctx, remedies[0].ID, remedies[0].Attempts, true, "", time.Time{}, now.Add(11*time.Second), ); err != nil { t.Fatal(err) } dismissed, found, err := service.Case(ctx, appealed.Case.ID) if err != nil || !found || dismissed.Case.Status != domain.ModerationCaseDismissed { t.Fatalf("dismissed=%+v found=%v err=%v", dismissed, found, err) } if _, found, err := service.ResolveAppealLink( ctx, expiredToken, now.Add(10*time.Second), ); err != nil || found { t.Fatalf("expired resolve found=%v err=%v", found, err) } if _, _, err := service.SubmitAppealLink( ctx, expiredToken, "too late", now.Add(10*time.Second), ); !errors.Is(err, domain.ErrModerationAppealLinkInvalid) { t.Fatalf("expired submit err=%v", err) } if _, _, err := service.ResolveAppealLink(ctx, "not-a-token", now); !errors.Is( err, domain.ErrModerationAppealLinkInvalid, ) { t.Fatalf("invalid token err=%v", err) } }