Fix IM report and group permission regressions

This commit is contained in:
richjohnrambo
2026-09-02 10:01:38 +08:00
parent 15a4174ed1
commit ac45223e39
5 changed files with 299 additions and 39 deletions
@@ -0,0 +1,56 @@
//go:build mysql
package mysql
import (
"os"
"strings"
"testing"
"github.com/tinode/chat/server/store/types"
)
func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) {
if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) {
t.Fatal("all visibility should be allowed to use managed report scope")
}
for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} {
if canUseManagedReports(organizationVisibility{Scope: scope}) {
t.Fatalf("scope %q should not be allowed to use managed report scope", scope)
}
}
}
func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) {
where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops")
joined := strings.Join(where, " AND ")
if !strings.Contains(joined, "m.department_id=?") {
t.Fatalf("department filter missing from where: %s", joined)
}
if !strings.Contains(joined, "im_friendships") {
t.Fatalf("friend visibility filter missing from where: %s", joined)
}
if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend {
t.Fatalf("unexpected args: %#v", args)
}
}
func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) {
mode := groupAdminAccessMode()
if !mode.IsAdmin() {
t.Fatalf("admin mode should grant admin access: %s", mode.String())
}
if mode.IsOwner() {
t.Fatalf("admin mode must not grant owner access: %s", mode.String())
}
}
func TestMessageSearchDoesNotClearScannedHead(t *testing.T) {
source, err := os.ReadFile("tenant_adapter.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(source), "msg.Head = kvMapFromJSON(msg.Head)") {
t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it")
}
}
+156 -26
View File
@@ -2337,7 +2337,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
return nil, "", err
}
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -2398,7 +2397,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui
return nil, "", err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit {
nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue
@@ -4593,12 +4591,24 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if from != "" {
@@ -4622,10 +4632,6 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -4769,14 +4775,14 @@ func (a *adapter) TenantDailyReportSubmit(id t.TenantID, uid t.Uid, reportID str
return a.TenantDailyReportGet(id, uid, reportID)
}
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, _ string) (*t.DailyReportStats, error) {
func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, departmentID string) (*t.DailyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportStats(id, uid, date, "")
return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -4786,11 +4792,22 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
if cancel != nil {
defer cancel()
}
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports WHERE report_date=? AND status=?`, date.Format("2006-01-02"), t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.DailyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_date=?", "r.status=?"}
submittedArgs := []any{date.Format("2006-01-02"), t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
@@ -4800,20 +4817,114 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim
return stats, nil
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, _ time.Time, userIDs []t.Uid, _ string) ([]t.Uid, error) {
func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) {
memberWhere := []string{"m.employment_state='active'"}
memberArgs := []any{}
if strings.TrimSpace(departmentID) != "" {
memberWhere = append(memberWhere, "m.department_id=?")
memberArgs = append(memberArgs, strings.TrimSpace(departmentID))
}
visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m")
if visibilitySQL != "" {
memberWhere = append(memberWhere, visibilitySQL)
memberArgs = append(memberArgs, visibilityArgs...)
}
return memberWhere, memberArgs
}
func canUseManagedReports(visibility organizationVisibility) bool {
return visibility.Scope == orgVisibilityAll
}
func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantDailyReportRemind(id, uid, time.Time{}, userIDs, "")
return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
return userIDs, nil
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if !canUseManagedReports(visibility) {
return nil, t.ErrPermissionDenied
}
targetIDs := make([]int64, 0, len(userIDs))
seen := map[int64]struct{}{}
for _, target := range userIDs {
if target.IsZero() {
return nil, t.ErrMalformed
}
decoded := decodeUID(target)
if decoded <= 0 {
return nil, t.ErrMalformed
}
if _, ok := seen[decoded]; ok {
continue
}
seen[decoded] = struct{}{}
targetIDs = append(targetIDs, decoded)
}
query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m
WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS (
SELECT 1 FROM im_daily_reports AS r
WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=?
)`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted)
if err != nil {
return nil, err
}
query = a.db.Rebind(query)
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
reminded := make([]t.Uid, 0, len(targetIDs))
for rows.Next() {
var raw int64
if err = rows.Scan(&raw); err != nil {
return nil, err
}
target := store.EncodeUid(raw)
reminded = append(reminded, target)
}
if err = rows.Err(); err != nil {
return nil, err
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for _, target := range reminded {
eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey))
if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{
"date": date.Format("2006-01-02"),
}); err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
committed = true
return reminded, nil
}
func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) {
@@ -4869,12 +4980,24 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
if limit <= 0 || limit > 100 {
limit = 50
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
where := []string{"1=1"}
args := []any{}
if scope == "" || scope == "mine" {
where = append(where, "author_id=?")
args = append(args, decodeUID(uid))
} else if scope != "managed" {
} else if scope == "managed" {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, "", err
}
if !canUseManagedReports(visibility) {
return nil, "", t.ErrPermissionDenied
}
} else {
return nil, "", t.ErrMalformed
}
if fromMonth != "" {
@@ -4898,10 +5021,6 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM
args = append(args, parsed)
}
args = append(args, limit+1)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, "", err
@@ -5076,14 +5195,14 @@ func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID s
return a.TenantMonthlyReportGet(id, uid, reportID)
}
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) {
func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, departmentID string) (*t.MonthlyReportStats, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantMonthlyReportStats(id, uid, month, "")
return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
@@ -5093,11 +5212,22 @@ func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ st
if cancel != nil {
defer cancel()
}
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
visibility, err := a.organizationVisibility(ctx, uid)
if err != nil {
return nil, err
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports WHERE report_month=? AND status=?`, month, t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil {
memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID)
stats := &t.MonthlyReportStats{}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil {
return nil, err
}
submittedWhere := []string{"r.report_month=?", "r.status=?"}
submittedArgs := []any{month, t.DailyReportStatusSubmitted}
if len(memberWhere) > 0 {
submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`)
submittedArgs = append(submittedArgs, memberArgs...)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil {
return nil, err
}
stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount
+9 -5
View File
@@ -1170,8 +1170,8 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target))
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target))
if err != nil {
return err
}
@@ -1194,6 +1194,10 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ
return tx.Commit()
}
func groupAdminAccessMode() t.AccessMode {
return t.ModeCPublic | t.ModeApprove
}
func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
@@ -1214,10 +1218,10 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
uid t.Uid
mode t.AccessMode
role string
}{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
}{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid))
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}