安全与可靠性: 修复配额并发、投票事务、密码授权与导出完整性
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||
)
|
||||
@@ -53,10 +54,41 @@ func TestVoteSubmissions(t *testing.T) {
|
||||
func userIDPtr(id uint) *uint { return &id }
|
||||
func tokenPtr(s string) *string { return &s }
|
||||
|
||||
// seedPublishedVotes 创建 n 个已发布的投票,每个投票带两个选项。
|
||||
// 返回 voteID 列表;调用方用 votes[i] 作为 VoteID、options[voteID][j] 作为 OptionID。
|
||||
// VoteSubmissions.Create 现在要求投票必须 published 才接受提交,旧的测试夹具
|
||||
// 直接用 VoteID=1 的虚拟行会被锁行失败拒绝,因此需要真实投票。
|
||||
func seedPublishedVotes(t *testing.T, ctx context.Context, conn *gorm.DB, userID uint, n int) ([]uint, map[uint][]uint) {
|
||||
t.Helper()
|
||||
votesStore := NewVotesStore(conn)
|
||||
optionsStore := NewVoteOptionsStore(conn)
|
||||
voteIDs := make([]uint, 0, n)
|
||||
optionIDs := make(map[uint][]uint, n)
|
||||
for i := 0; i < n; i++ {
|
||||
vote, err := votesStore.Create(ctx, CreateVoteOptions{
|
||||
UserID: userID, Title: "seed vote", Type: VoteTypeSingle,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, votesStore.UpdateStatus(ctx, vote.ID, VoteStatusPublished))
|
||||
voteIDs = append(voteIDs, vote.ID)
|
||||
optionIDs[vote.ID] = make([]uint, 0, 2)
|
||||
for j := 0; j < 2; j++ {
|
||||
opt, err := optionsStore.Create(ctx, CreateVoteOptionOptions{
|
||||
VoteID: vote.ID, Content: "option",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
optionIDs[vote.ID] = append(optionIDs[vote.ID], opt.ID)
|
||||
}
|
||||
}
|
||||
return voteIDs, optionIDs
|
||||
}
|
||||
|
||||
func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
got, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 2,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][1],
|
||||
UserID: userIDPtr(10),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -67,9 +99,11 @@ func testVoteSubmissionsCreate(t *testing.T, ctx context.Context, db *voteSubmis
|
||||
|
||||
// 核心防刷:同一登录用户对同一投票重复提交必须被数据库唯一索引拦截。
|
||||
func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(10),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -77,8 +111,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
|
||||
|
||||
t.Run("duplicate rejected", func(t *testing.T) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 2,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][1],
|
||||
UserID: userIDPtr(10),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -87,8 +121,8 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
|
||||
|
||||
t.Run("different user allowed", func(t *testing.T) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(11),
|
||||
VoterIP: "114.5.1.5",
|
||||
})
|
||||
@@ -98,9 +132,11 @@ func testVoteSubmissionsUniqueUser(t *testing.T, ctx context.Context, db *voteSu
|
||||
|
||||
// 核心防刷:同一匿名指纹对同一投票重复提交必须被拦截。
|
||||
func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
VoterToken: tokenPtr("fp-token-001"),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -108,8 +144,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
|
||||
|
||||
t.Run("duplicate rejected", func(t *testing.T) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 2,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][1],
|
||||
VoterToken: tokenPtr("fp-token-001"),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -118,8 +154,8 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
|
||||
|
||||
t.Run("different token allowed", func(t *testing.T) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
VoterToken: tokenPtr("fp-token-002"),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -129,17 +165,19 @@ func testVoteSubmissionsUniqueToken(t *testing.T, ctx context.Context, db *voteS
|
||||
|
||||
// 登录用户与匿名指纹可在同一投票下并存(user_id 为 NULL 与 voter_token 为 NULL 不冲突)。
|
||||
func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(10),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 2,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][1],
|
||||
VoterToken: tokenPtr("fp-anon-001"),
|
||||
VoterIP: "114.5.1.6",
|
||||
})
|
||||
@@ -147,25 +185,26 @@ func testVoteSubmissionsUserAndAnonCoexist(t *testing.T, ctx context.Context, db
|
||||
|
||||
// 同一 user 换匿名 token 再来:user_id 相同仍应被拦截
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(10),
|
||||
VoterToken: tokenPtr("fp-anon-002"),
|
||||
VoterIP: "114.5.1.7",
|
||||
})
|
||||
require.Equal(t, ErrVoteAlreadyVoted, err)
|
||||
|
||||
count, err := db.CountByVoteID(ctx, 1)
|
||||
count, err := db.CountByVoteID(ctx, voteID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
// 同一用户 / 同一 token 对不同投票不受约束。
|
||||
func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
for voteID := uint(1); voteID <= 2; voteID++ {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
|
||||
for _, voteID := range voteIDs {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: voteID,
|
||||
OptionID: 1,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(10),
|
||||
VoterIP: "114.5.1.4",
|
||||
})
|
||||
@@ -173,50 +212,50 @@ func testVoteSubmissionsCrossVote(t *testing.T, ctx context.Context, db *voteSub
|
||||
}
|
||||
|
||||
// 同一 token 投两个不同投票也允许
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoterToken: tokenPtr("fp-x"),
|
||||
VoterIP: "114.5.1.8",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 2,
|
||||
OptionID: 1,
|
||||
VoterToken: tokenPtr("fp-x"),
|
||||
VoterIP: "114.5.1.8",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
for _, voteID := range voteIDs {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
VoterToken: tokenPtr("fp-x"),
|
||||
VoterIP: "114.5.1.8",
|
||||
})
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
count, err := db.CountByVoteID(ctx, 1)
|
||||
count, err := db.CountByVoteID(ctx, voteIDs[0])
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), count)
|
||||
}
|
||||
|
||||
func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
// 投票 1:选项 1 两票,选项 2 一票
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
|
||||
vote1, vote2 := voteIDs[0], voteIDs[1]
|
||||
opt1a, opt1b := optionIDs[vote1][0], optionIDs[vote1][1]
|
||||
opt2a := optionIDs[vote2][0]
|
||||
|
||||
// 投票 1:选项 A 两票,选项 B 一票
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(1), VoterIP: "a"})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1a, UserID: userIDPtr(2), VoterIP: "b"})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 2, UserID: userIDPtr(3), VoterIP: "c"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: opt1b, UserID: userIDPtr(3), VoterIP: "c"})
|
||||
require.Nil(t, err)
|
||||
// 投票 2:一票
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(4), VoterIP: "d"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: opt2a, UserID: userIDPtr(4), VoterIP: "d"})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Run("total", func(t *testing.T) {
|
||||
got, err := db.CountByVoteID(ctx, 1)
|
||||
got, err := db.CountByVoteID(ctx, vote1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(3), got)
|
||||
})
|
||||
|
||||
t.Run("by option", func(t *testing.T) {
|
||||
got, err := db.CountByOptionID(ctx, 1, 1)
|
||||
got, err := db.CountByOptionID(ctx, vote1, opt1a)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), got)
|
||||
|
||||
got, err = db.CountByOptionID(ctx, 1, 2)
|
||||
got, err = db.CountByOptionID(ctx, vote1, opt1b)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), got)
|
||||
})
|
||||
@@ -229,54 +268,62 @@ func testVoteSubmissionsCount(t *testing.T, ctx context.Context, db *voteSubmiss
|
||||
}
|
||||
|
||||
func testVoteSubmissionsHasUserVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(10), VoterIP: "a"})
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(10), VoterIP: "a"})
|
||||
require.Nil(t, err)
|
||||
|
||||
got, err := db.HasUserVoted(ctx, 1, 10)
|
||||
got, err := db.HasUserVoted(ctx, voteID, 10)
|
||||
require.Nil(t, err)
|
||||
require.True(t, got)
|
||||
|
||||
got, err = db.HasUserVoted(ctx, 1, 11)
|
||||
got, err = db.HasUserVoted(ctx, voteID, 11)
|
||||
require.Nil(t, err)
|
||||
require.False(t, got)
|
||||
}
|
||||
|
||||
func testVoteSubmissionsHasTokenVoted(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], VoterToken: tokenPtr("fp-1"), VoterIP: "a"})
|
||||
require.Nil(t, err)
|
||||
|
||||
got, err := db.HasTokenVoted(ctx, 1, "fp-1")
|
||||
got, err := db.HasTokenVoted(ctx, voteID, "fp-1")
|
||||
require.Nil(t, err)
|
||||
require.True(t, got)
|
||||
|
||||
got, err = db.HasTokenVoted(ctx, 1, "fp-2")
|
||||
got, err = db.HasTokenVoted(ctx, voteID, "fp-2")
|
||||
require.Nil(t, err)
|
||||
require.False(t, got)
|
||||
}
|
||||
|
||||
func testVoteSubmissionsCountByIP(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(1), VoterIP: "1.2.3.4"})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(2), VoterIP: "1.2.3.4"})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: voteID, OptionID: optionIDs[voteID][0], UserID: userIDPtr(3), VoterIP: "5.6.7.8"})
|
||||
require.Nil(t, err)
|
||||
|
||||
got, err := db.CountByIP(ctx, 1, "1.2.3.4", since)
|
||||
got, err := db.CountByIP(ctx, voteID, "1.2.3.4", since)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), got)
|
||||
|
||||
got, err = db.CountByIP(ctx, 1, "5.6.7.8", since)
|
||||
got, err = db.CountByIP(ctx, voteID, "5.6.7.8", since)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), got)
|
||||
}
|
||||
|
||||
func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 1)
|
||||
voteID := voteIDs[0]
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{
|
||||
VoteID: 1,
|
||||
OptionID: 1,
|
||||
VoteID: voteID,
|
||||
OptionID: optionIDs[voteID][0],
|
||||
UserID: userIDPtr(uint(100 + i)),
|
||||
VoterIP: "x",
|
||||
FormData: datatypes.JSON(`{"nickname":"user` + string(rune('0'+i)) + `"}`),
|
||||
@@ -284,25 +331,27 @@ func testVoteSubmissionsListByVoteID(t *testing.T, ctx context.Context, db *vote
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
got, err := db.ListByVoteID(ctx, 1, &dbutil.Cursor{Value: 2, PageSize: 10})
|
||||
got, err := db.ListByVoteID(ctx, voteID, &dbutil.Cursor{Value: 2, PageSize: 10})
|
||||
require.Nil(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, uint(1), got[0].ID, "游标取 id < 2 的记录")
|
||||
}
|
||||
|
||||
func testVoteSubmissionsDeleteByVoteID(t *testing.T, ctx context.Context, db *voteSubmissions) {
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 1, OptionID: 1, UserID: userIDPtr(1), VoterIP: "a"})
|
||||
voteIDs, optionIDs := seedPublishedVotes(t, ctx, db.DB, 1, 2)
|
||||
vote1, vote2 := voteIDs[0], voteIDs[1]
|
||||
_, err := db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote1, OptionID: optionIDs[vote1][0], UserID: userIDPtr(1), VoterIP: "a"})
|
||||
require.Nil(t, err)
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: 2, OptionID: 1, UserID: userIDPtr(2), VoterIP: "b"})
|
||||
_, err = db.Create(ctx, CreateVoteSubmissionOptions{VoteID: vote2, OptionID: optionIDs[vote2][0], UserID: userIDPtr(2), VoterIP: "b"})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Nil(t, db.DeleteByVoteID(ctx, 1))
|
||||
require.Nil(t, db.DeleteByVoteID(ctx, vote1))
|
||||
|
||||
got, err := db.CountByVoteID(ctx, 1)
|
||||
got, err := db.CountByVoteID(ctx, vote1)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), got)
|
||||
|
||||
got, err = db.CountByVoteID(ctx, 2)
|
||||
got, err = db.CountByVoteID(ctx, vote2)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user