feat: add safety reporting APIs

This commit is contained in:
richjohnrambo
2026-08-14 00:01:54 +08:00
parent fa75c0f0f9
commit 170bbdca76
10 changed files with 1344 additions and 6 deletions
+172 -1
View File
@@ -112,6 +112,13 @@ type testDevicePushTokenStore struct {
token *types.DevicePushToken
}
type testSafetyStore struct {
riskChallenge *types.RiskChallenge
reasons []types.AbuseReason
abuseReport *types.AbuseReport
feedback *types.FeedbackSubmission
}
func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) {
if s.version == 0 {
s.version = 1
@@ -370,6 +377,65 @@ func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DeviceP
return token, nil
}
func (s *testSafetyStore) CreateRiskChallenge(_ types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
now := time.Now().UTC().Round(time.Millisecond)
challenge.CreatedAt = now
challenge.UpdatedAt = now
s.riskChallenge = challenge
return challenge, nil
}
func (s *testSafetyStore) ConfirmRiskChallenge(_ types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
if s.riskChallenge == nil {
s.riskChallenge = &types.RiskChallenge{ChallengeID: challengeID, UserID: uid, Action: "open_url", Level: "high", NoticeCode: "url_high", ExpiresAt: now.Add(time.Minute)}
}
s.riskChallenge.State = types.RiskChallengeStateCanceled
if decision == "continue" {
s.riskChallenge.State = types.RiskChallengeStateContinued
}
s.riskChallenge.Decision = decision
s.riskChallenge.ActionTokenID = actionTokenID
s.riskChallenge.UpdatedAt = now
return s.riskChallenge, nil
}
func (s *testSafetyStore) AbuseReasons(_ types.TenantID, targetType string) ([]types.AbuseReason, error) {
if len(s.reasons) == 0 {
s.reasons = []types.AbuseReason{{TargetType: targetType, ReasonCode: "fraud", Label: "诈骗欺诈", AllowsEvidence: true}}
}
return s.reasons, nil
}
func (s *testSafetyStore) CreateAbuseReport(_ types.TenantID, report *types.AbuseReport, _ string) (*types.AbuseReport, error) {
now := time.Now().UTC().Round(time.Millisecond)
report.Status = types.AbuseReportStatusReceived
report.CreatedAt = now
report.UpdatedAt = now
s.abuseReport = report
return report, nil
}
func (s *testSafetyStore) FeedbackConfig(types.TenantID, types.Uid) (*types.FeedbackConfig, error) {
now := time.Now().UTC().Round(time.Millisecond)
return &types.FeedbackConfig{
Categories: []string{"bug", "suggestion"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"},
AllowContact: true,
DiagnosticsNotice: "仅在同意后上传诊断信息。",
UpdatedAt: now,
}, nil
}
func (s *testSafetyStore) CreateFeedback(_ types.TenantID, feedback *types.FeedbackSubmission, _ string) (*types.FeedbackSubmission, error) {
now := time.Now().UTC().Round(time.Millisecond)
feedback.Status = types.FeedbackStatusReceived
feedback.CreatedAt = now
feedback.UpdatedAt = now
s.feedback = feedback
return feedback, nil
}
func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
return nil
}
@@ -2435,6 +2501,7 @@ func TestServeIMSecurityURLCheck(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldSafety := store.Safety
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
@@ -2442,7 +2509,11 @@ func TestServeIMSecurityURLCheck(t *testing.T) {
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
store.Store = mockStore
defer func() { store.Store = oldStore }()
store.Safety = &testSafetyStore{}
defer func() {
store.Store = oldStore
store.Safety = oldSafety
}()
allowReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"https://example.com/a","source":"message"}`))
allowReq.Header.Set("Authorization", "Bearer token-1")
@@ -2467,6 +2538,106 @@ func TestServeIMSecurityURLCheck(t *testing.T) {
}
}
func TestServeIMRiskChallengeConfirm(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSafety := store.Safety
oldSecurity := store.Security
now := time.Now().UTC().Round(time.Millisecond)
store.Safety = &testSafetyStore{riskChallenge: &types.RiskChallenge{
ChallengeID: "risk-1", UserID: 42, Action: "open_url", Level: "high", NoticeCode: "url_high",
State: types.RiskChallengeStatePending, ExpiresAt: now.Add(time.Minute), CreatedAt: now, UpdatedAt: now,
}}
store.Security = &testSecurityStore{}
defer func() {
store.Safety = oldSafety
store.Security = oldSecurity
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk-1/confirm", bytes.NewBufferString(`{"decision":"continue"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMRiskChallengeConfirm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["state"] != types.RiskChallengeStateContinued || data["risk_action_token"] == "" {
t.Fatalf("unexpected risk confirm response: %#v", data)
}
}
func TestServeIMAbuseReportsAndReasons(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSafety := store.Safety
oldUsers := store.Users
safety := &testSafetyStore{}
store.Safety = safety
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
users.EXPECT().Get(types.TenantID(7), types.Uid(43)).Return(&types.User{}, nil).AnyTimes()
defer func() {
store.Safety = oldSafety
store.Users = oldUsers
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=user", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMAbuseReasons(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reasons status: got %d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse",
bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "abuse-1")
rec = httptest.NewRecorder()
serveIMAbuseReports(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.abuseReport == nil || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" {
t.Fatalf("unexpected abuse report: %#v", safety.abuseReport)
}
}
func TestServeIMFeedback(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSafety := store.Safety
safety := &testSafetyStore{}
store.Safety = safety
defer func() { store.Safety = oldSafety }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/feedback/config", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFeedbackConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config status: got %d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/feedback",
bytes.NewBufferString(`{"category":"bug","content":"页面显示异常","contact":"me@example.com","attachment_file_ids":["file1"],"client_context":{"platform":"android"},"include_diagnostics":true}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "feedback-1")
rec = httptest.NewRecorder()
serveIMFeedback(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.feedback == nil || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics {
t.Fatalf("unexpected feedback: %#v", safety.feedback)
}
}
func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()