diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..cd3bf9d --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,38 @@ +name: Golang Validation + +on: + push: + branches: [master, dev] + pull_request: + branches: [master, dev] + +jobs: + golang-checks: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: [1.24] + + steps: + - uses: actions/checkout@v2 + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Install dependencies + run: | + go mod download + - name: Tidy dependencies + run: | + go mod tidy -diff + - name: Check Format + run: | + gofmt -s -l database logging sse *.go + - name: Run Tests + run: | + go test ./database + - name: Run vet + run: | + go vet ./database/ ./logging/ ./sse/ + go vet *.go diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..78b4c46 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,21 @@ +name: SonarQube + +on: + push: + branches: + - dev + + +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 19ec15d..4cf406a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,12 @@ FROM docker.io/golang:1.24-alpine AS build WORKDIR /src/ RUN apk add git -COPY go* . -COPY *.go . +COPY go.* . +RUN go mod download # do this before build for caching COPY database database COPY logging logging COPY sse sse +COPY *.go . RUN go build -v -o vote FROM docker.io/alpine diff --git a/README.md b/README.md index 5d8fc2e..15b7eb3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Implementation ## Configuration -You'll need to set up these values in your environment. Ask an RTP for OIDC credentials. A docker-compose file is provided for convenience. Otherwise, I trust you to figure it out! +If you're using the compose file, you'll need to ask an RTP for the vote-dev OIDC secret, and set it as `VOTE_OIDC_SECRET` in your environment + +If you're not using the compose file, you'll need more of these ``` VOTE_HOST=http://localhost:8080 @@ -27,10 +29,32 @@ VOTE_SLACK_APP_TOKEN= VOTE_SLACK_BOT_TOKEN= ``` +### Dev Overrides +`DEV_DISABLE_ACTIVE_FILTERS="true"` will disable the requirements that you be active to vote +`DEV_FORCE_IS_EVALS="true"` will force vote to treat all users as the Evals director + +## Linting +These will be checked by CI + +``` +# tidy dependencies +go mod tidy + +# format all code according to go standards +gofmt -w -s *.go logging sse database + +# run tests (database is the first place we've defined tests) +go test ./database + +# run heuristic validation +go vet ./database/ ./logging/ ./sse/ +go vet *.go +``` + ## To-Dos - [ ] Don't let the user fuck it up - [ ] Show E-Board polls with a higher priority -- [ ] Move Hide Vote to create instead of after you vote :skull: +- [x] Move Hide Vote to create instead of after you vote :skull: - [ ] Display the reason why a user is on the results page of a running poll - [ ] Display minimum time left that a poll is open diff --git a/constitutional.go b/constitutional.go index 70ce6c2..d9c98f6 100644 --- a/constitutional.go +++ b/constitutional.go @@ -151,8 +151,14 @@ func EvaluatePolls() { logging.Logger.WithFields(logrus.Fields{"method": "EvaluatePolls close"}).Error(err) continue } + announceStr := "The vote \"" + poll.ShortDescription + "\" has closed." + if !poll.Hidden { + announceStr += " Check out the results at " + pollLink + } else { + announceStr += " Results will be posted shortly." + } _, _, _, err = slackData.Client.SendMessage(slackData.AnnouncementsChannel, - slack.MsgOptionText("The vote \""+poll.ShortDescription+"\" has closed. Check out the results at "+pollLink, false)) + slack.MsgOptionText(announceStr, false)) if err != nil { logging.Logger.WithFields(logrus.Fields{"method": "EvaluatePolls announce"}).Error(err) } diff --git a/database/database.go b/database/database.go index 423344f..d03bc42 100644 --- a/database/database.go +++ b/database/database.go @@ -4,6 +4,7 @@ import ( "context" "os" "strings" + "testing" "time" "github.com/computersciencehouse/vote/logging" @@ -20,10 +21,16 @@ const ( Updated UpsertResult = 1 ) -var Client = Connect() +var Client *mongo.Client = Connect() var db = "" func Connect() *mongo.Client { + // This always gets invoked on initialisation. bad! it'd be nice if we only did this setup in main rather than components under test. for now we just skip if testing + if testing.Testing() { + logging.Logger.WithFields(logrus.Fields{"module": "database", "method": "Connect"}).Info("testing, not doing db connection, someone should mock this someday") + return nil + } + logging.Logger.WithFields(logrus.Fields{"module": "database", "method": "Connect"}).Info("beginning database connection") ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) diff --git a/database/poll.go b/database/poll.go index bc1ab84..5c26640 100644 --- a/database/poll.go +++ b/database/poll.go @@ -2,11 +2,15 @@ package database import ( "context" + "sort" "time" + "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" + + "github.com/computersciencehouse/vote/logging" ) type Poll struct { @@ -21,8 +25,11 @@ type Poll struct { Gatekeep bool `bson:"gatekeep"` QuorumType float64 `bson:"quorumType"` AllowedUsers []string `bson:"allowedUsers"` - Hidden bool `bson:"hidden"` AllowWriteIns bool `bson:"writeins"` + + // Prevent this poll from having progress displayed + // This is important for events like elections where the results shouldn't be visible mid vote + Hidden bool `bson:"hidden"` } const POLL_TYPE_SIMPLE = "simple" @@ -69,20 +76,6 @@ func (poll *Poll) Hide(ctx context.Context) error { return nil } -func (poll *Poll) Reveal(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - objId, _ := primitive.ObjectIDFromHex(poll.Id) - - _, err := Client.Database(db).Collection("polls").UpdateOne(ctx, map[string]interface{}{"_id": objId}, map[string]interface{}{"$set": map[string]interface{}{"hidden": false}}) - if err != nil { - return err - } - - return nil -} - func CreatePoll(ctx context.Context, poll *Poll) (string, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -150,32 +143,32 @@ func GetClosedVotedPolls(ctx context.Context, userId string) ([]*Poll, error) { cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{ {{ - "$match", bson.D{ - {"userId", userId}, + Key: "$match", Value: bson.D{ + {Key: "userId", Value: userId}, }, }}, {{ - "$lookup", bson.D{ - {"from", "polls"}, - {"localField", "pollId"}, - {"foreignField", "_id"}, - {"as", "polls"}, + Key: "$lookup", Value: bson.D{ + {Key: "from", Value: "polls"}, + {Key: "localField", Value: "pollId"}, + {Key: "foreignField", Value: "_id"}, + {Key: "as", Value: "polls"}, }, }}, {{ - "$unwind", bson.D{ - {"path", "$polls"}, - {"preserveNullAndEmptyArrays", false}, + Key: "$unwind", Value: bson.D{ + {Key: "path", Value: "$polls"}, + {Key: "preserveNullAndEmptyArrays", Value: false}, }, }}, {{ - "$replaceRoot", bson.D{ - {"newRoot", "$polls"}, + Key: "$replaceRoot", Value: bson.D{ + {Key: "newRoot", Value: "$polls"}, }, }}, {{ - "$match", bson.D{ - {"open", false}, + Key: "$match", Value: bson.D{ + {Key: "open", Value: false}, }, }}, }) @@ -189,6 +182,104 @@ func GetClosedVotedPolls(ctx context.Context, userId string) ([]*Poll, error) { return polls, nil } +// calculateRankedResult determines a result for a ranked choice vote +// votesRaw is the RankedVote entries that are returned directly from the database +// The algorithm defined in the Constitution as of 26 Nov 2025 is as follows: +// +// > The winning option is selected outright if it gains more than half the votes +// > cast as a first preference. If not, the option with the fewest number of first +// > preference votes is eliminated and their votes move to the second preference +// > marked on the ballots. This process continues until one option has half of the +// > votes cast and is elected. +// +// The return value consists of a list of voting rounds. Each round contains a +// mapping of the vote options to their vote share for that round. If the vote +// is not decided in a given round, there will be a subsequent round with the +// option that had the fewest votes eliminated, and its votes redistributed. +// +// The last entry in this list is the final round, and the option with the most +// votes in this round is the winner. If all options have the same, then it is +// unfortunately a tie, and the vote is not resolvable, as there is no lowest +// option to eliminate. +func calculateRankedResult(ctx context.Context, votesRaw []RankedVote) ([]map[string]int, error) { + // We want to store those that were eliminated so we don't accidentally reinclude them + eliminated := make([]string, 0) + votes := make([][]string, 0) + finalResult := make([]map[string]int, 0) + + //change ranked votes from a map (which is unordered) to a slice of votes (which is ordered) + //order is from first preference to last preference + for _, vote := range votesRaw { + optionList := orderOptions(ctx, vote.Options) + votes = append(votes, optionList) + } + + round := 0 + // Iterate until we have a winner + for { + round = round + 1 + // Contains candidates to number of votes in this round + tallied := make(map[string]int) + voteCount := 0 + for _, picks := range votes { + // Go over picks until we find a non-eliminated candidate + for _, candidate := range picks { + if !containsValue(eliminated, candidate) { + if _, ok := tallied[candidate]; ok { + tallied[candidate]++ + } else { + tallied[candidate] = 1 + } + voteCount += 1 + break + } + } + } + // Eliminate lowest vote getter + minVote := 1000000 //the smallest number of votes received thus far (to find who is in last) + minPerson := make([]string, 0) //the person(s) with the least votes that need removed + for person, vote := range tallied { + if vote < minVote { // this should always be true round one, to set a true "who is in last" + minVote = vote + minPerson = make([]string, 0) + minPerson = append(minPerson, person) + } else if vote == minVote { + minPerson = append(minPerson, person) + } + } + eliminated = append(eliminated, minPerson...) + finalResult = append(finalResult, tallied) + + // TODO this should probably include some poll identifier + logging.Logger.WithFields(logrus.Fields{"round": round, "tallies": tallied, "threshold": voteCount / 2}).Debug("round report") + + // If one person has all the votes, they win + if len(tallied) == 1 { + break + } + + end := true + for str, val := range tallied { + // if any particular entry is above half remaining votes, they win and it ends + if val > (voteCount / 2) { + finalResult = append(finalResult, map[string]int{str: val}) + end = true + break + } + // Check if all values in tallied are the same + // In that case, it's a tie? + if val != minVote { + end = false + } + } + if end { + break + } + } + return finalResult, nil + +} + func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -202,15 +293,15 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) { pollResult := make(map[string]int) cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{ {{ - "$match", bson.D{ - {"pollId", pollId}, + Key: "$match", Value: bson.D{ + {Key: "pollId", Value: pollId}, }, }}, {{ - "$group", bson.D{ - {"_id", "$option"}, - {"count", bson.D{ - {"$sum", 1}, + Key: "$group", Value: bson.D{ + {Key: "_id", Value: "$option"}, + {Key: "count", Value: bson.D{ + {Key: "$sum", Value: 1}, }}, }, }}, @@ -234,14 +325,11 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) { return finalResult, nil case POLL_TYPE_RANKED: - // We want to store those that were eliminated - eliminated := make([]string, 0) - // Get all votes cursor, err := Client.Database(db).Collection("votes").Aggregate(ctx, mongo.Pipeline{ {{ - "$match", bson.D{ - {"pollId", pollId}, + Key: "$match", Value: bson.D{ + {Key: "pollId", Value: pollId}, }, }}, }) @@ -250,76 +338,7 @@ func (poll *Poll) GetResult(ctx context.Context) ([]map[string]int, error) { } var votesRaw []RankedVote cursor.All(ctx, &votesRaw) - - votes := make([][]string, 0) - - //change ranked votes from a map (which is unordered) to a slice of votes (which is ordered) - //order is from first preference to last preference - for _, vote := range votesRaw { - temp, cf := context.WithTimeout(context.Background(), 1*time.Second) - optionList := orderOptions(vote.Options, temp) - cf() - votes = append(votes, optionList) - } - - // Iterate until we have a winner - for { - // Contains candidates to number of votes in this round - tallied := make(map[string]int) - voteCount := 0 - for _, picks := range votes { - // Go over picks until we find a non-eliminated candidate - for _, candidate := range picks { - if !containsValue(eliminated, candidate) { - if _, ok := tallied[candidate]; ok { - tallied[candidate]++ - } else { - tallied[candidate] = 1 - } - voteCount += 1 - break - } - } - } - // Eliminate lowest vote getter - minVote := 1000000 //the smallest number of votes received thus far (to find who is in last) - minPerson := make([]string, 0) //the person(s) with the least votes that need removed - for person, vote := range tallied { - if vote < minVote { // this should always be true round one, to set a true "who is in last" - minVote = vote - minPerson = make([]string, 0) - minPerson = append(minPerson, person) - } else if vote == minVote { - minPerson = append(minPerson, person) - } - } - eliminated = append(eliminated, minPerson...) - finalResult = append(finalResult, tallied) - // If one person has all the votes, they win - if len(tallied) == 1 { - break - } - - end := true - for str, val := range tallied { - // if any particular entry is above half remaining votes, they win and it ends - if val > (voteCount / 2) { - finalResult = append(finalResult, map[string]int{str: val}) - end = true - break - } - // Check if all values in tallied are the same - // In that case, it's a tie? - if val != minVote { - end = false - break - } - } - if end { - break - } - } - return finalResult, nil + return calculateRankedResult(ctx, votesRaw) } return nil, nil } @@ -333,21 +352,35 @@ func containsValue(slice []string, value string) bool { return false } -func orderOptions(options map[string]int, ctx context.Context) []string { - result := make([]string, 0, len(options)) - order := 1 - for order <= len(options) { - for option, preference := range options { - select { - case <-ctx.Done(): - return make([]string, 0) - default: - if preference == order { - result = append(result, option) - order += 1 - } - } - } +// orderOptions takes a RankedVote's options, and returns an ordered list of +// their choices +// +// it's invalid for a vote to list the same number multiple times, the output +// will vary based on the map ordering of the options, and so is not guaranteed +// to be deterministic +// +// ctx is no longer used, as this function is not expected to hang, but remains +// an argument per golang standards +// +// the return values is the option keys, ordered from lowest to highest +func orderOptions(ctx context.Context, options map[string]int) []string { + // Figure out all the ranks they've listed + var ranks []int = make([]int, len(options)) + reverse_map := make(map[int]string) + i := 0 + for option, rank := range options { + ranks[i] = rank + reverse_map[rank] = option + i += 1 } - return result + + sort.Ints(ranks) + + // normalise the ranks for counts that don't start at 1 + var choices []string = make([]string, len(ranks)) + for idx, rank := range ranks { + choices[idx] = reverse_map[rank] + } + + return choices } diff --git a/database/poll_test.go b/database/poll_test.go new file mode 100644 index 0000000..9787399 --- /dev/null +++ b/database/poll_test.go @@ -0,0 +1,225 @@ +package database + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func makeVotes() []RankedVote { + // so inpyt for this, we want to have option, then a list of ranks. + // am tempted to have some shorthand for generating test cases more easily + return []RankedVote{} +} + +func TestResultCalcs(t *testing.T) { + // for votes, we only need to define options, we don't currently rely on IDs + tests := []struct { + name string + votes []RankedVote + results []map[string]int + err error + }{ + { + name: "Empty Votes", + votes: []RankedVote{ + { + Options: map[string]int{}, + }, + }, + results: []map[string]int{ + {}, + }, + }, + { + name: "1 vote", + votes: []RankedVote{ + { + Options: map[string]int{ + "first": 1, + "second": 2, + "third": 3, + }, + }, + }, + results: []map[string]int{ + { + "first": 1, + }, + }, + }, + { + name: "Tie vote", + votes: []RankedVote{ + { + Options: map[string]int{ + "first": 1, + "second": 2, + }, + }, + { + Options: map[string]int{ + "first": 2, + "second": 1, + }, + }, + }, + results: []map[string]int{ + { + "first": 1, + "second": 1, + }, + }, + }, + { + name: "Several Rounds", + votes: []RankedVote{ + { + Options: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + }, + { + Options: map[string]int{ + "a": 2, + "b": 1, + "c": 3, + }, + }, + { + Options: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + }, + { + Options: map[string]int{ + "a": 2, + "b": 1, + "c": 3, + }, + }, + { + Options: map[string]int{ + "a": 2, + "b": 3, + "c": 1, + }, + }, + }, + results: []map[string]int{ + { + "a": 2, + "b": 2, + "c": 1, + }, + { + "a": 3, + "b": 2, + }, + { + "a": 3, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + results, err := calculateRankedResult(context.Background(), test.votes) + assert.Equal(t, test.results, results) + assert.Equal(t, test.err, err) + }) + } +} + +func TestOrderOptions(t *testing.T) { + tests := []struct { + name string + input map[string]int + output []string + }{ + { + name: "SimpleOrder", + input: map[string]int{ + "one": 1, + "two": 2, + "three": 3, + "four": 4, + }, + output: []string{"one", "two", "three", "four"}, + }, + { + name: "Reversed", + input: map[string]int{ + "one": 4, + "two": 3, + "three": 2, + "four": 1, + }, + output: []string{"four", "three", "two", "one"}, + }, + { + name: "HighStart", + input: map[string]int{ + "one": 2, + "two": 3, + "three": 4, + "four": 5, + }, + output: []string{"one", "two", "three", "four"}, + }, + { + name: "LowStart", + input: map[string]int{ + "one": 0, + "two": 1, + "three": 2, + "four": 3, + }, + output: []string{"one", "two", "three", "four"}, + }, + { + name: "Negative", + input: map[string]int{ + "one": -1, + "two": 1, + "three": 2, + "four": 3, + }, + output: []string{"one", "two", "three", "four"}, + }, + { + name: "duplicate, expect bad output", + input: map[string]int{ + "one": 0, + "two": 1, + "three": 1, + "four": 2, + }, + output: []string{"one", "three", "three", "four"}, + }, + { + name: "Gap", + input: map[string]int{ + "one": 1, + "two": 2, + "three": 4, + "four": 5, + }, + output: []string{"one", "two", "three", "four"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + assert.Equal(t, test.output, orderOptions(ctx, test.input)) + }) + } +} diff --git a/database/ranked_vote.go b/database/ranked_vote.go index 1ca69e0..b9761e7 100644 --- a/database/ranked_vote.go +++ b/database/ranked_vote.go @@ -13,7 +13,6 @@ type RankedVote struct { Options map[string]int `bson:"options"` } - func CastRankedVote(ctx context.Context, vote *RankedVote, voter *Voter) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() diff --git a/docker-compose.yaml b/docker-compose.yaml index a64adf3..22f5f75 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,8 @@ services: VOTE_OIDC_ID: vote-dev VOTE_OIDC_SECRET: "${VOTE_OIDC_SECRET}" VOTE_STATE: 27a28540e47ec786b7bdad03f83171b3 + DEV_DISABLE_ACTIVE_FILTERS: "${DEV_DISABLE_ACTIVE_FILTERS}" + DEV_FORCE_IS_EVALS: "${DEV_FORCE_IS_EVALS}" ports: - "127.0.0.1:8080:8080" diff --git a/go.mod b/go.mod index b7787fe..c16c793 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/computersciencehouse/csh-auth v0.1.0 github.com/gin-gonic/gin v1.11.0 github.com/sirupsen/logrus v1.9.3 + github.com/slack-go/slack v0.17.3 + github.com/stretchr/testify v1.11.1 go.mongodb.org/mongo-driver v1.17.6 mvdan.cc/xurls/v2 v2.6.0 ) @@ -16,6 +18,7 @@ require ( github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/coreos/go-oidc v2.4.0+incompatible // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -35,10 +38,10 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pquerna/cachecontrol v0.2.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect - github.com/slack-go/slack v0.17.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -57,4 +60,5 @@ require ( golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8a1d4c0..b325fdf 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -97,8 +99,6 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -148,6 +148,7 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= diff --git a/logging/logger.go b/logging/logger.go index 3956b17..f3d2d6c 100644 --- a/logging/logger.go +++ b/logging/logger.go @@ -3,19 +3,29 @@ package logging import ( "os" "runtime" + "testing" "github.com/sirupsen/logrus" ) -var Logger = &logrus.Logger{ - Out: os.Stdout, - Formatter: &logrus.TextFormatter{ - DisableLevelTruncation: true, - PadLevelText: true, - FullTimestamp: true, - }, - Hooks: make(logrus.LevelHooks), - Level: logrus.InfoLevel, +var Logger *logrus.Logger = makeLogger() + +func makeLogger() *logrus.Logger { + // TODO should this someday be configurable? + level := logrus.InfoLevel + if testing.Testing() { + level = logrus.DebugLevel + } + return &logrus.Logger{ + Out: os.Stdout, + Formatter: &logrus.TextFormatter{ + DisableLevelTruncation: true, + PadLevelText: true, + FullTimestamp: true, + }, + Hooks: make(logrus.LevelHooks), + Level: level, + } } func Trace() runtime.Frame { diff --git a/main.go b/main.go index 45c7596..89b8c04 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,10 @@ var VOTE_TOKEN = os.Getenv("VOTE_TOKEN") var CONDITIONAL_GATEKEEP_URL = os.Getenv("VOTE_CONDITIONAL_URL") var VOTE_HOST = os.Getenv("VOTE_HOST") +// Dev mode flags +var DEV_DISABLE_ACTIVE_FILTERS bool = os.Getenv("DEV_DISABLE_ACTIVE_FILTERS") == "true" +var DEV_FORCE_IS_EVALS bool = os.Getenv("DEV_FORCE_IS_EVALS") == "true" + func inc(x int) string { return strconv.Itoa(x + 1) } @@ -67,6 +71,7 @@ func main() { r.GET("/auth/callback", csh.AuthCallback) r.GET("/auth/logout", csh.AuthLogout) + // TODO: change ALL the response codes to use http.(actual description) r.GET("/", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) @@ -110,7 +115,7 @@ func main() { r.GET("/create", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) - if !slices.Contains(claims.UserInfo.Groups, "active") { + if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { c.HTML(403, "unauthorized.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, @@ -121,14 +126,14 @@ func main() { c.HTML(200, "create.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, - "IsEvals": containsString(claims.UserInfo.Groups, "eboard-evaluations"), + "IsEvals": isEvals(claims.UserInfo), }) })) r.POST("/create", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) - if !slices.Contains(claims.UserInfo.Groups, "active") { + if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { c.HTML(403, "unauthorized.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, @@ -156,9 +161,9 @@ func main() { OpenedTime: time.Now(), Open: true, QuorumType: quorum, - Hidden: false, Gatekeep: c.PostForm("gatekeep") == "true", AllowWriteIns: c.PostForm("allowWriteIn") == "true", + Hidden: c.PostForm("hidden") == "true", } if c.PostForm("rankedChoice") == "true" { poll.VoteType = database.POLL_TYPE_RANKED @@ -182,7 +187,7 @@ func main() { poll.Options = []string{"Pass", "Fail", "Abstain"} } if poll.Gatekeep { - if !slices.Contains(claims.UserInfo.Groups, "eboard-evaluations") { + if !isEvals(claims.UserInfo) { c.HTML(403, "unauthorized.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, @@ -228,9 +233,6 @@ func main() { } canModify := containsString(claims.UserInfo.Groups, "active_rtp") || containsString(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username - if poll.Gatekeep { - canModify = false - } c.HTML(200, "poll.tmpl", gin.H{ "Id": poll.Id, @@ -298,8 +300,23 @@ func main() { UserId: claims.UserInfo.Username, } - maxNum := len(poll.Options) - voted := make([]bool, maxNum) + fmt.Println(poll.Options) + + for _, option := range poll.Options { + optionRankStr := c.PostForm(option) + optionRank, err := strconv.Atoi(optionRankStr) + + if len(optionRankStr) < 1 { + continue + } + if err != nil { + c.JSON(400, gin.H{"error": "non-number ranking"}) + return + } + + vote.Options[option] = optionRank + } + // process write-in if c.PostForm("writeinOption") != "" && c.PostForm("writein") != "" { for candidate := range vote.Options { @@ -318,27 +335,24 @@ func main() { return } vote.Options[c.PostForm("writeinOption")] = rank - maxNum += 1 //you can rank all options in the poll PLUS one } - for _, opt := range poll.Options { - option := c.PostForm(opt) - rank, err := strconv.Atoi(option) - if len(option) < 1 { - continue - } - if err != nil { - c.JSON(400, gin.H{"error": "non-number ranking"}) - return - } + maxNum := len(vote.Options) + voted := make([]bool, maxNum) + + for _, rank := range vote.Options { if rank > 0 && rank <= maxNum { - vote.Options[opt] = rank + if voted[rank-1] { + c.JSON(400, gin.H{"error": "You ranked two or more candidates at the same level"}) + return + } voted[rank-1] = true } else { c.JSON(400, gin.H{"error": fmt.Sprintf("votes must be from 1 - %d", maxNum)}) return } } + rankedCandidates := len(vote.Options) for _, voteOpt := range vote.Options { if voteOpt > rankedCandidates { @@ -379,14 +393,6 @@ func main() { return } - if poll.Hidden && poll.CreatedBy != claims.UserInfo.Username { - c.HTML(403, "hidden.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - return - } - results, err := poll.GetResult(c) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) @@ -394,9 +400,6 @@ func main() { } canModify := containsString(claims.UserInfo.Groups, "active_rtp") || containsString(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username - if poll.Gatekeep { - canModify = false - } c.HTML(200, "result.tmpl", gin.H{ "Id": poll.Id, @@ -409,6 +412,7 @@ func main() { "CanModify": canModify, "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, + "Gatekeep": poll.Gatekeep, }) })) @@ -449,43 +453,6 @@ func main() { c.Redirect(302, "/results/"+poll.Id) })) - r.POST("/poll/:id/reveal", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - if poll.CreatedBy != claims.UserInfo.Username { - c.JSON(403, gin.H{"error": "Only the creator can reveal a poll result"}) - return - } - - err = poll.Reveal(c) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - pId, _ := primitive.ObjectIDFromHex(poll.Id) - action := database.Action{ - Id: "", - PollId: pId, - Date: primitive.NewDateTimeFromTime(time.Now()), - User: claims.UserInfo.Username, - Action: "Reveal Results", - } - err = database.WriteAction(c, &action) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.Redirect(302, "/results/"+poll.Id) - })) - r.POST("/poll/:id/close", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) @@ -540,13 +507,18 @@ func main() { r.Run() } +// isEvals determines if the current user is evals, allowing for a dev mode override +func isEvals(user cshAuth.CSHUserInfo) bool { + return DEV_FORCE_IS_EVALS || containsString(user.Groups, "eboard-evaluations") +} + // canVote determines whether a user can cast a vote. // // returns an integer value: 0 is success, 1 is database error, 3 is not active, 4 is gatekept, 9 is already voted // TODO: use the return value to influence messages shown on results page func canVote(user cshAuth.CSHUserInfo, poll database.Poll, allowedUsers []string) int { // always false if user is not active - if !slices.Contains(user.Groups, "active") { + if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(user.Groups, "active") { return 3 } voted, err := database.HasVoted(context.Background(), poll.Id, user.Username) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..24c2592 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectKey=ComputerScienceHouse_vote_c5ba863d-30d7-4fa9-97dd-4f2c58f8f5fa \ No newline at end of file diff --git a/templates/create.tmpl b/templates/create.tmpl index d892a3f..1fbeeba 100644 --- a/templates/create.tmpl +++ b/templates/create.tmpl @@ -72,6 +72,14 @@ > Ranked Choice Vote +
+ + Hide Results Until Vote is Complete +
{{ if .IsEvals }}
- Gatekeep Required + Gatekeep Required (Require Quorum, Limit Voters, Force Automatic Close)