387 lines
14 KiB
Go
387 lines
14 KiB
Go
package moderation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/admin"
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
type captureModerationAdmin struct {
|
|
userFlags []admin.SetUserFlagsRequest
|
|
frozen []admin.SetAccountFrozenRequest
|
|
}
|
|
|
|
type captureModerationAccountDeleter struct {
|
|
result domain.AccountDeletionResult
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (d *captureModerationAccountDeleter) ExecuteAccountDeletion(context.Context, int64, domain.AccountDeletionSource, string, time.Time) (domain.AccountDeletionResult, error) {
|
|
d.calls++
|
|
return d.result, d.err
|
|
}
|
|
|
|
type captureModerationAccountDeletionNotifier struct {
|
|
results []domain.AccountDeletionResult
|
|
}
|
|
|
|
func (n *captureModerationAccountDeletionNotifier) NotifyModerationAccountDeletion(_ context.Context, result domain.AccountDeletionResult) {
|
|
n.results = append(n.results, result)
|
|
}
|
|
|
|
func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) {
|
|
a.frozen = append(a.frozen, req)
|
|
return admin.CommandResult{}, nil
|
|
}
|
|
|
|
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
|
a.userFlags = append(a.userFlags, req)
|
|
return admin.CommandResult{}, nil
|
|
}
|
|
|
|
func (*captureModerationAdmin) SetChannelFlags(context.Context, admin.SetChannelFlagsRequest) (admin.CommandResult, error) {
|
|
return admin.CommandResult{}, nil
|
|
}
|
|
|
|
func (*captureModerationAdmin) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) {
|
|
return admin.CommandResult{}, nil
|
|
}
|
|
|
|
func TestActionWorkerAppliesFakeFlagAndResolvesCase(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Add(-10 * time.Second)
|
|
reports := memory.NewModerationReportStore()
|
|
service := NewService(reports)
|
|
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)
|
|
}
|
|
decision, created, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
|
Actor: "reviewer", Reason: "impersonation confirmed",
|
|
CommandID: "mod-fake-1", Kind: domain.ModerationDecisionViolation,
|
|
Actions: []domain.ModerationActionDraft{{
|
|
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
|
}},
|
|
CreatedAt: now.Add(2 * time.Second),
|
|
})
|
|
if err != nil || !created || decision.Case.Status != domain.ModerationCaseActionPending {
|
|
t.Fatalf("decision=%+v created=%v err=%v", decision, created, err)
|
|
}
|
|
adminActions := &captureModerationAdmin{}
|
|
worker := NewActionWorker(
|
|
reports,
|
|
NewActionExecutor(adminActions, nil, nil, nil),
|
|
zap.NewNop(),
|
|
)
|
|
if err := worker.runOnce(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(adminActions.userFlags) != 1 {
|
|
t.Fatalf("flag actions=%d, want 1", len(adminActions.userFlags))
|
|
}
|
|
flag := adminActions.userFlags[0]
|
|
if flag.UserID != target.ID || flag.Scam || !flag.Fake ||
|
|
flag.CommandID != "mod-fake-1:000" || flag.Actor != "reviewer" {
|
|
t.Fatalf("flag request=%+v", flag)
|
|
}
|
|
resolved, found, err := service.Case(ctx, claimed.ID)
|
|
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved ||
|
|
resolved.Actions[0].Status != domain.ModerationActionSucceeded {
|
|
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
|
|
}
|
|
}
|
|
|
|
func TestActionWorkerSupersedesOlderTargetSanction(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1_750_000_000, 0).UTC()
|
|
reports := memory.NewModerationReportStore()
|
|
service := NewService(reports)
|
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
|
createDecision := func(reporter int64, command string, kind domain.ModerationActionKind, at time.Time) int64 {
|
|
t.Helper()
|
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
|
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
|
Target: target, Reason: domain.ModerationReasonFake, Option: command,
|
|
Items: []domain.ModerationReportItem{{
|
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
|
Evidence: []byte(`{"schema_version":1}`),
|
|
}},
|
|
CreatedAt: at,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
|
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
|
Target: target, Limit: 10,
|
|
})
|
|
if err != nil || len(cases) != 1 {
|
|
t.Fatalf("open cases=%+v err=%v", cases, err)
|
|
}
|
|
claimed, err := service.ClaimCase(ctx, cases[0].ID, cases[0].Version, "reviewer", at.Add(time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
|
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
|
Kind: domain.ModerationDecisionViolation,
|
|
Actions: []domain.ModerationActionDraft{{Kind: kind, Payload: []byte(`{}`)}},
|
|
CreatedAt: at.Add(2 * time.Second),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return claimed.ID
|
|
}
|
|
oldCaseID := createDecision(101, "old-scam", domain.ModerationActionMarkScam, now)
|
|
newCaseID := createDecision(102, "new-fake", domain.ModerationActionMarkFake, now.Add(3*time.Second))
|
|
|
|
adminActions := &captureModerationAdmin{}
|
|
worker := NewActionWorker(
|
|
reports,
|
|
NewActionExecutor(adminActions, nil, nil, nil),
|
|
zap.NewNop(),
|
|
)
|
|
if err := worker.runOnce(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(adminActions.userFlags) != 1 || adminActions.userFlags[0].Scam ||
|
|
!adminActions.userFlags[0].Fake {
|
|
t.Fatalf("flag actions=%+v", adminActions.userFlags)
|
|
}
|
|
oldDetail, _, err := service.Case(ctx, oldCaseID)
|
|
if err != nil || oldDetail.Case.Status != domain.ModerationCaseResolved ||
|
|
len(oldDetail.Actions) != 1 ||
|
|
oldDetail.Actions[0].Status != domain.ModerationActionSuperseded {
|
|
t.Fatalf("old detail=%+v err=%v", oldDetail, err)
|
|
}
|
|
newDetail, _, err := service.Case(ctx, newCaseID)
|
|
if err != nil || newDetail.Case.Status != domain.ModerationCaseResolved ||
|
|
len(newDetail.Actions) != 1 ||
|
|
newDetail.Actions[0].Status != domain.ModerationActionSucceeded {
|
|
t.Fatalf("new detail=%+v err=%v", newDetail, err)
|
|
}
|
|
}
|
|
|
|
func TestAppealCannotClearSanctionOwnedByNewerCase(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1_750_000_000, 0).UTC()
|
|
reports := memory.NewModerationReportStore()
|
|
service := NewService(reports)
|
|
target := domain.Peer{Type: domain.PeerTypeUser, ID: 202}
|
|
createCase := func(reporter int64, option, command string, at time.Time) domain.ModerationCase {
|
|
t.Helper()
|
|
if _, _, err := service.AcceptReport(ctx, domain.ModerationReportDraft{
|
|
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
|
|
Target: target, Reason: domain.ModerationReasonFake, Option: option,
|
|
Items: []domain.ModerationReportItem{{
|
|
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
|
|
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
|
|
Evidence: []byte(`{"schema_version":1}`),
|
|
}},
|
|
CreatedAt: at,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, err := service.ListCases(ctx, domain.ModerationCaseFilter{
|
|
Statuses: []domain.ModerationCaseStatus{domain.ModerationCaseOpen},
|
|
Target: target, Limit: 10,
|
|
})
|
|
if err != nil || len(items) != 1 {
|
|
t.Fatalf("open cases=%+v err=%v", items, err)
|
|
}
|
|
claimed, err := service.ClaimCase(ctx, items[0].ID, items[0].Version, "reviewer", at.Add(time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := service.DecideCase(ctx, domain.ModerationDecisionRequest{
|
|
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
|
|
Actor: "reviewer", Reason: "confirmed", CommandID: command,
|
|
Kind: domain.ModerationDecisionViolation,
|
|
Actions: []domain.ModerationActionDraft{{
|
|
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
|
|
}},
|
|
CreatedAt: at.Add(2 * time.Second),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
detail, _, err := service.Case(ctx, claimed.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return detail.Case
|
|
}
|
|
oldCase := createCase(101, "old", "old", now)
|
|
adminActions := &captureModerationAdmin{}
|
|
worker := NewActionWorker(reports, NewActionExecutor(adminActions, nil, nil, nil), zap.NewNop())
|
|
if err := worker.runOnce(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appeal, _, err := service.SubmitAppeal(ctx, oldCase.ID, target.ID, "mistake", now.Add(3*time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newCase := createCase(102, "new", "new", now.Add(4*time.Second))
|
|
if newCase.ID == oldCase.ID {
|
|
t.Fatal("new report reused decided case")
|
|
}
|
|
oldDetail, _, err := service.Case(ctx, oldCase.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, err := service.ClaimCase(ctx, oldCase.ID, oldDetail.Case.Version, "reviewer", now.Add(8*time.Second))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err = service.ReviewAppeal(ctx, domain.ModerationDecisionRequest{
|
|
CaseID: oldCase.ID, AppealID: appeal.ID,
|
|
ExpectedVersion: claimed.Version, Actor: "reviewer",
|
|
Reason: "grant", CommandID: "stale-appeal",
|
|
Kind: domain.ModerationDecisionAppealGrant,
|
|
Actions: []domain.ModerationActionDraft{{
|
|
Kind: domain.ModerationActionClearPeerFlags, Payload: []byte(`{}`),
|
|
}},
|
|
CreatedAt: now.Add(9 * time.Second),
|
|
})
|
|
if !errors.Is(err, domain.ErrModerationActionConflict) {
|
|
t.Fatalf("ReviewAppeal error=%v", err)
|
|
}
|
|
}
|
|
|
|
type captureAppealLinkIssuer struct {
|
|
caseID int64
|
|
appellantID int64
|
|
expiresAt time.Time
|
|
issuedAt time.Time
|
|
returnedToken string
|
|
}
|
|
|
|
func (i *captureAppealLinkIssuer) IssueAppealLink(_ context.Context, caseID, appellantUserID int64, expiresAt, now time.Time) (string, error) {
|
|
i.caseID = caseID
|
|
i.appellantID = appellantUserID
|
|
i.expiresAt = expiresAt
|
|
i.issuedAt = now
|
|
return i.returnedToken, nil
|
|
}
|
|
|
|
func TestActionExecutorFreezeDefaultsAndBoundsAppealLink(t *testing.T) {
|
|
now := time.Unix(1_750_000_000, 0).UTC()
|
|
adminActions := &captureModerationAdmin{}
|
|
issuer := &captureAppealLinkIssuer{returnedToken: "token"}
|
|
executor := NewActionExecutor(
|
|
adminActions, nil, nil, nil,
|
|
WithActionClock(func() time.Time { return now }),
|
|
WithAppealLinks(issuer, "https://example.test/"),
|
|
)
|
|
detail := domain.ModerationCaseDetail{
|
|
Case: domain.ModerationCase{
|
|
ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20},
|
|
},
|
|
Decisions: []domain.ModerationDecision{{
|
|
ID: 30, Actor: "reviewer",
|
|
}},
|
|
}
|
|
action := domain.ModerationAction{
|
|
CaseID: 10, DecisionID: 30,
|
|
Kind: domain.ModerationActionFreezeAccount,
|
|
Payload: []byte(`{}`), CommandID: "freeze:000",
|
|
}
|
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(adminActions.frozen) != 1 {
|
|
t.Fatalf("freeze calls=%d", len(adminActions.frozen))
|
|
}
|
|
req := adminActions.frozen[0]
|
|
wantUntil := now.Add(30 * 24 * time.Hour)
|
|
if !req.Frozen || req.UserID != 20 || !req.Until.Equal(wantUntil) ||
|
|
req.AppealURL != "https://example.test/appeal/token" {
|
|
t.Fatalf("freeze request=%+v", req)
|
|
}
|
|
if issuer.caseID != 10 || issuer.appellantID != 20 ||
|
|
!issuer.expiresAt.Equal(wantUntil) || !issuer.issuedAt.Equal(now) {
|
|
t.Fatalf("appeal issue=%+v", issuer)
|
|
}
|
|
|
|
adminActions.frozen = nil
|
|
longUntil := now.Add(365 * 24 * time.Hour)
|
|
action.Payload = []byte(`{"until":"` + longUntil.Format(time.RFC3339Nano) + `"}`)
|
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !adminActions.frozen[0].Until.Equal(longUntil) {
|
|
t.Fatalf("long freeze until=%v", adminActions.frozen[0].Until)
|
|
}
|
|
if want := now.Add(domain.MaxModerationAppealLinkLifetime); !issuer.expiresAt.Equal(want) {
|
|
t.Fatalf("link expiry=%v want=%v", issuer.expiresAt, want)
|
|
}
|
|
}
|
|
|
|
func TestActionExecutorNotifiesCommittedAccountDeletion(t *testing.T) {
|
|
revoked := domain.Authorization{AuthKeyID: [8]byte{7}, UserID: 20}
|
|
result := domain.AccountDeletionResult{
|
|
User: domain.User{ID: 20, Deleted: true},
|
|
Changed: true,
|
|
RevokedAuthorizations: []domain.Authorization{revoked},
|
|
}
|
|
accounts := &captureModerationAccountDeleter{result: result}
|
|
notifier := &captureModerationAccountDeletionNotifier{}
|
|
executor := NewActionExecutor(nil, nil, nil, accounts, WithAccountDeletionNotifier(notifier))
|
|
detail := domain.ModerationCaseDetail{
|
|
Case: domain.ModerationCase{ID: 10, Target: domain.Peer{Type: domain.PeerTypeUser, ID: 20}},
|
|
Decisions: []domain.ModerationDecision{{ID: 30, Actor: "reviewer"}},
|
|
}
|
|
action := domain.ModerationAction{
|
|
CaseID: 10, DecisionID: 30, Kind: domain.ModerationActionDeleteAccount,
|
|
Payload: []byte(`{}`), CommandID: "delete-account:000",
|
|
}
|
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if accounts.calls != 1 || len(notifier.results) != 1 {
|
|
t.Fatalf("delete calls=%d notifications=%d, want 1/1", accounts.calls, len(notifier.results))
|
|
}
|
|
got := notifier.results[0]
|
|
if !got.Changed || got.User.ID != result.User.ID || len(got.RevokedAuthorizations) != 1 || got.RevokedAuthorizations[0].AuthKeyID != revoked.AuthKeyID {
|
|
t.Fatalf("notification result=%+v, want committed deletion", got)
|
|
}
|
|
|
|
accounts.result = domain.AccountDeletionResult{User: result.User, Changed: false}
|
|
if err := executor.Execute(context.Background(), detail, action); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(notifier.results) != 1 {
|
|
t.Fatalf("notifications after unchanged deletion=%d, want 1", len(notifier.results))
|
|
}
|
|
|
|
withoutNotifier := NewActionExecutor(nil, nil, nil, accounts)
|
|
if err := withoutNotifier.Execute(context.Background(), detail, action); !errors.Is(err, domain.ErrModerationActionInvalid) {
|
|
t.Fatalf("delete without runtime notifier err=%v, want ErrModerationActionInvalid", err)
|
|
}
|
|
}
|