From 2e90b6172b8db7fd1f959eecdfca2e2a117e3f85 Mon Sep 17 00:00:00 2001 From: Umputun Date: Mon, 19 Aug 2019 14:04:12 -0500 Subject: [PATCH] Votes ip (#416) * change vote params to request * limit voting for the same ip * limit same ip vote duration * add same ip vote check for directions * wire RestrictVoteIP and duration * add votes-ip and votes-ip-time description --- README.md | 2 + backend/app/cmd/server.go | 4 + backend/app/rest/api/rest_private.go | 11 +- backend/app/rest/api/rest_private_test.go | 2 - backend/app/store/comment.go | 37 +++--- backend/app/store/service/service.go | 93 +++++++++++---- backend/app/store/service/service_test.go | 134 ++++++++++++++++++---- 7 files changed, 220 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 3e4ef145..38842637 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,8 @@ _this is the recommended way to run remark42_ | ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE | | max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit | | max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited | +| votes-ip | VOTES_IP |`false` | restrict votes from the same ip | +| votes-ip-time | VOTES_IP_TIME |`5m` | same ip vote restriction time, `0s` - unlimited | | low-score | LOW_SCORE | `-5` | low score threshold | | critical-score | CRITICAL_SCORE | `-10` | critical score threshold | | positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive | diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index ffdc5092..0922cd50 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -56,6 +56,8 @@ type ServerCommand struct { ImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"enable image proxy"` MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"` MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"` + RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"` + DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"` LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"` CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"` PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"` @@ -277,6 +279,8 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}), RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}), } + dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP + dataService.RestrictSameIPVotes.Duration = s.DurationVoteIP loadingCache, err := s.makeCache() if err != nil { diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index c66a3d33..66fa918a 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -40,7 +40,7 @@ type private struct { type privStore interface { Create(comment store.Comment) (commentID string, err error) EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error) - Vote(locator store.Locator, commentID string, userID string, val bool) (comment store.Comment, err error) + Vote(req service.VoteReq) (comment store.Comment, err error) Get(locator store.Locator, commentID string, user store.User) (store.Comment, error) User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) ValidateComment(c *store.Comment) error @@ -198,7 +198,14 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) { return } - comment, err := s.dataService.Vote(locator, id, user.ID, vote) + req := service.VoteReq{ + Locator: locator, + CommentID: id, + UserID: user.ID, + UserIP: strings.Split(r.RemoteAddr, ":")[0], + Val: vote, + } + comment, err := s.dataService.Vote(req) if err != nil { code := parseError(err, rest.ErrVoteRejected) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code) diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 7059bdb3..7b08df94 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -420,8 +420,6 @@ func TestRest_Vote(t *testing.T) { assert.Equal(t, -1, cr.Score) assert.Equal(t, 0, cr.Vote, "no vote info for different user") assert.Equal(t, map[string]bool(nil), cr.Votes) - - assert.Equal(t, map[string]bool(nil), cr.Votes) } func TestRest_UserAllData(t *testing.T) { diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index c7ac03ca..ad24d38e 100644 --- a/backend/app/store/comment.go +++ b/backend/app/store/comment.go @@ -11,21 +11,22 @@ import ( // Comment represents a single comment with optional reference to its parent type Comment struct { - ID string `json:"id" bson:"_id"` - ParentID string `json:"pid"` - Text string `json:"text"` - Orig string `json:"orig,omitempty"` - User User `json:"user"` - Locator Locator `json:"locator"` - Score int `json:"score"` - Votes map[string]bool `json:"votes,omitempty"` - Vote int `json:"vote"` // vote for the current user, -1/1/0. - Controversy float64 `json:"controversy,omitempty"` - Timestamp time.Time `json:"time" bson:"time"` - Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response - Pin bool `json:"pin,omitempty" bson:"pin,omitempty"` - Deleted bool `json:"delete,omitempty" bson:"delete"` - PostTitle string `json:"title,omitempty" bson:"title"` + ID string `json:"id" bson:"_id"` + ParentID string `json:"pid"` + Text string `json:"text"` + Orig string `json:"orig,omitempty"` + User User `json:"user"` + Locator Locator `json:"locator"` + Score int `json:"score"` + Votes map[string]bool `json:"votes,omitempty"` + VotedIPs map[string]VotedIPInfo `json:"voted_ips,omitempty"` // voted ips (hashes) with TS + Vote int `json:"vote"` // vote for the current user, -1/1/0. + Controversy float64 `json:"controversy,omitempty"` + Timestamp time.Time `json:"time" bson:"time"` + Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response + Pin bool `json:"pin,omitempty" bson:"pin,omitempty"` + Deleted bool `json:"delete,omitempty" bson:"delete"` + PostTitle string `json:"title,omitempty" bson:"title"` } // Locator keeps site and url of the post @@ -56,6 +57,12 @@ type BlockedUser struct { Until time.Time `json:"time"` } +// VotedIPInfo keeps timestamp and voting value (direction). Used as VotedIPs value +type VotedIPInfo struct { + Timestamp time.Time + Value bool +} + // DeleteMode defines how much comment info will be erased type DeleteMode int diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index b36d6647..acdf5f43 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -24,11 +24,15 @@ import ( // DataStore wraps store.Interface with additional methods type DataStore struct { - Engine engine.Interface - EditDuration time.Duration - AdminStore admin.Store - MaxCommentSize int - MaxVotes int + Engine engine.Interface + EditDuration time.Duration + AdminStore admin.Store + MaxCommentSize int + MaxVotes int + RestrictSameIPVotes struct { + Enabled bool + Duration time.Duration + } PositiveScore bool TitleExtractor *TitleExtractor RestrictedWordsMatcher *RestrictedWordsMatcher @@ -213,29 +217,47 @@ func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool) return s.Engine.Update(comment) } -// Vote for comment by id and locator -func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, val bool) (comment store.Comment, err error) { +// VoteReq is the request ot make a vote +type VoteReq struct { + Locator store.Locator + CommentID string + UserID string + UserIP string + Val bool +} - cLock := s.getScopedLocks(locator.URL) // get lock for URL scope - cLock.Lock() // prevents race on voting +// Vote for comment by id and locator +func (s *DataStore) Vote(req VoteReq) (comment store.Comment, err error) { + + cLock := s.getScopedLocks(req.Locator.URL) // get lock for URL scope + cLock.Lock() // prevents race on voting defer cLock.Unlock() - comment, err = s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID}) + comment, err = s.Engine.Get(engine.GetRequest{Locator: req.Locator, CommentID: req.CommentID}) if err != nil { return comment, err } - if comment.User.ID == userID && userID != "dev" { - return comment, errors.Errorf("user %s can not vote for his own comment %s", userID, commentID) + if comment.User.ID == req.UserID && req.UserID != "dev" { + return comment, errors.Errorf("user %s can not vote for his own comment %s", req.UserID, req.CommentID) } if comment.Votes == nil { comment.Votes = make(map[string]bool) } - v, voted := comment.Votes[userID] - if voted && v == val { - return comment, errors.Errorf("user %s already voted for %s", userID, commentID) + v, voted := comment.Votes[req.UserID] + if voted && v == req.Val { + return comment, errors.Errorf("user %s already voted for %s", req.UserID, req.CommentID) + } + + secret, err := s.AdminStore.Key() + if err != nil { + return store.Comment{}, errors.Wrapf(err, "can't get secret for site %s", comment.Locator.SiteID) + } + userIPHash := store.HashValue(req.UserIP, secret) + if s.isSameIPVote(req, userIPHash, comment) { + return comment, errors.Errorf("the same ip %s already voted for %s", userIPHash, req.CommentID) } maxVotes := s.MaxVotes // 0 value allowed and treated as "no comments allowed" @@ -244,32 +266,39 @@ func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, } if maxVotes >= 0 && len(comment.Votes) >= maxVotes { - return comment, errors.Errorf("maximum number of votes exceeded for comment %s", commentID) + return comment, errors.Errorf("maximum number of votes exceeded for comment %s", req.CommentID) } - if s.PositiveScore && comment.Score <= 0 && !val { - return comment, errors.Errorf("minimal score reached for comment %s", commentID) + if s.PositiveScore && comment.Score <= 0 && !req.Val { + return comment, errors.Errorf("minimal score reached for comment %s", req.CommentID) } // reset vote if user changed to opposite - if voted && v != val { - delete(comment.Votes, userID) + if voted && v != req.Val { + delete(comment.Votes, req.UserID) } // add to voted map if first vote if !voted { - comment.Votes[userID] = val + comment.Votes[req.UserID] = req.Val } + // add ip hash to voted ip map + if comment.VotedIPs == nil { + comment.VotedIPs = map[string]store.VotedIPInfo{} + } + + comment.VotedIPs[userIPHash] = store.VotedIPInfo{Timestamp: time.Now(), Value: req.Val} + // update score - if val { + if req.Val { comment.Score++ } else { comment.Score-- } comment.Vote = 0 - if vv, ok := comment.Votes[userID]; ok { + if vv, ok := comment.Votes[req.UserID]; ok { if vv { comment.Vote = 1 } else { @@ -278,10 +307,26 @@ func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, } comment.Controversy = s.controversy(s.upsAndDowns(comment)) - comment.Locator = locator + comment.Locator = req.Locator return comment, s.Engine.Update(comment) } +func (s *DataStore) isSameIPVote(req VoteReq, userIPHash string, comment store.Comment) bool { + if req.UserIP == "" || !s.RestrictSameIPVotes.Enabled { + return false + } + + if v, ipFound := comment.VotedIPs[userIPHash]; ipFound { + if v.Value != req.Val { + return false // opposite direction vote allowed + } + if s.RestrictSameIPVotes.Duration == 0 || v.Timestamp.Add(s.RestrictSameIPVotes.Duration).After(time.Now()) { + return true + } + } + return false +} + // controversy calculates controversial index of votes // source - https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L60 func (s *DataStore) controversy(ups, downs int) float64 { diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index 2573f791..08871394 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -183,7 +183,14 @@ func TestService_Vote(t *testing.T) { assert.Equal(t, map[string]bool(nil), res[0].Votes, "no votes initially") // vote +1 as user1 - c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true) + req := VoteReq{ + Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, + CommentID: res[0].ID, + UserID: "user1", + UserIP: "123", + Val: true, + } + c, err := b.Vote(req) assert.NoError(t, err) assert.Equal(t, 1, c.Score) assert.Equal(t, 1, c.Vote) @@ -201,10 +208,24 @@ func TestService_Vote(t *testing.T) { assert.Equal(t, 0, c.Vote, "can't see other user vote result") assert.Nil(t, c.Votes) - c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user", true) + req = VoteReq{ + Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, + CommentID: res[0].ID, + UserID: "user", + UserIP: "123", + Val: true, + } + c, err = b.Vote(req) assert.NotNil(t, err, "self-voting not allowed") - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true) + req = VoteReq{ + Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, + CommentID: res[0].ID, + UserID: "user1", + UserIP: "123", + Val: true, + } + _, err = b.Vote(req) assert.NotNil(t, err, "double-voting rejected") assert.True(t, strings.HasPrefix(err.Error(), "user user1 already voted")) @@ -226,7 +247,14 @@ func TestService_Vote(t *testing.T) { assert.Equal(t, 0, res[0].Vote) assert.Equal(t, 0.0, res[0].Controversy) - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", false) + req = VoteReq{ + Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, + CommentID: res[0].ID, + UserID: "user1", + UserIP: "123", + Val: false, + } + _, err = b.Vote(req) assert.NoError(t, err, "vote reset") res, err = b.Last("radio-t", 0, time.Time{}, store.User{}) assert.NoError(t, err) @@ -240,17 +268,21 @@ func TestService_VoteLimit(t *testing.T) { defer teardown(t) b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 2} - _, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true) + _, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user2", Val: true}) assert.NoError(t, err) - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user3", true) + _, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user3", Val: true}) assert.NoError(t, err) - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user4", true) + _, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user4", Val: true}) assert.NotNil(t, err, "vote limit reached") assert.True(t, strings.HasPrefix(err.Error(), "maximum number of votes exceeded for comment id-1")) - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user4", true) + _, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user4", Val: true}) assert.NoError(t, err) } @@ -258,7 +290,8 @@ func TestService_VotesDisabled(t *testing.T) { defer teardown(t) b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: 0} - _, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", true) + _, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user2", Val: true}) assert.EqualError(t, err, "maximum number of votes exceeded for comment id-1") } @@ -282,7 +315,8 @@ func TestService_VoteAggressive(t *testing.T) { assert.Equal(t, map[string]bool(nil), res[0].Votes, "no votes initially") // add a vote as user2 - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user2", true) + _, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID, + UserID: "user2", Val: true}) require.NoError(t, err) // crazy vote +1 as user1 @@ -291,7 +325,8 @@ func TestService_VoteAggressive(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", true) + _, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID, + UserID: "user1", Val: true}) }() } wg.Wait() @@ -311,7 +346,8 @@ func TestService_VoteAggressive(t *testing.T) { go func() { defer wg.Done() val := rand.Intn(2) > 0 - _, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", val) + _, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID, + UserID: "user1", Val: val}) }() } wg.Wait() @@ -344,8 +380,8 @@ func TestService_VoteConcurrent(t *testing.T) { ii := i go func() { defer wg.Done() - _, _ = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, - fmt.Sprintf("user1-%d", ii), true) + _, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID, + UserID: fmt.Sprintf("user1-%d", ii), Val: true}) }() } wg.Wait() @@ -361,15 +397,18 @@ func TestService_VotePositive(t *testing.T) { b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1, PositiveScore: true} - _, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", false) + _, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user2", Val: false}) assert.EqualError(t, err, "minimal score reached for comment id-1") - _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user3", true) + _, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user3", Val: true}) assert.NoError(t, err, "minimal score doesn't affect positive vote") b = DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1, PositiveScore: false} - c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-1", "user2", false) + c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-1", + UserID: "user2", Val: false}) assert.NoError(t, err, "minimal score ignored") assert.Equal(t, -1, c.Score) assert.Equal(t, 0.0, c.Controversy) @@ -379,17 +418,20 @@ func TestService_VoteControversy(t *testing.T) { defer teardown(t) b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), MaxVotes: -1} - c, err := b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user2", false) + c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user2", Val: false}) assert.NoError(t, err) assert.Equal(t, -1, c.Score, "should have -1 score") assert.InDelta(t, 0.00, c.Controversy, 0.01) - c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user3", true) + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user3", Val: true}) assert.NoError(t, err) assert.Equal(t, 0, c.Score, "should have 0 score") assert.InDelta(t, 2.00, c.Controversy, 0.01) - c, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-2", "user4", true) + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user4", Val: true}) assert.NoError(t, err) assert.Equal(t, 1, c.Score, "should have 1 score") assert.InDelta(t, 1.73, c.Controversy, 0.01) @@ -401,6 +443,58 @@ func TestService_VoteControversy(t *testing.T) { assert.InDelta(t, 1.73, res[0].Controversy, 0.01) } +func TestService_VoteSameIP(t *testing.T) { + defer teardown(t) + b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), + MaxVotes: -1} + b.RestrictSameIPVotes.Enabled = true + + c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user2", UserIP: "123", Val: true}) + assert.NoError(t, err) + assert.Equal(t, 1, c.Score, "should have 1 score") + + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user3", UserIP: "123", Val: true}) + assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2") + assert.Equal(t, 1, c.Score, "still have 1 score") + + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user3", UserIP: "123", Val: false}) + assert.NoError(t, err) + assert.Equal(t, 0, c.Score, "reset to 0 score, opposite vote allowed") +} + +func TestService_VoteSameIPWithDuration(t *testing.T) { + defer teardown(t) + b := DataStore{Engine: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123"), + MaxVotes: -1} + b.RestrictSameIPVotes.Enabled = true + b.RestrictSameIPVotes.Duration = 50 * time.Millisecond + + c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user2", UserIP: "123", Val: true}) + assert.NoError(t, err) + assert.Equal(t, 1, c.Score, "should have 1 score") + + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user3", UserIP: "123", Val: true}) + assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2") + assert.Equal(t, 1, c.Score, "still have 1 score") + + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user4", UserIP: "12345", Val: true}) + assert.NoError(t, err) + assert.Equal(t, 2, c.Score, "have 2 score") + + time.Sleep(51 * time.Millisecond) + + c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2", + UserID: "user3", UserIP: "123", Val: true}) + assert.NoError(t, err) + assert.Equal(t, 3, c.Score, "have 3 score") +} + func TestService_Controversy(t *testing.T) { tbl := []struct { ups, downs int