owpengram-server/internal/app/moderation/appeal_links_test.go
2026-07-24 11:56:59 +08:00

178 lines
6.1 KiB
Go

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)
}
}