From 6ee629d5f80a9f7b44f68a8fa7b9dd54d6ed50f6 Mon Sep 17 00:00:00 2001 From: Thiago de Freitas Figueiredo Date: Tue, 30 Jun 2020 12:22:34 -0300 Subject: [PATCH] politeiawww: Add unit tests to RFP functions. --- politeiad/cache/testcache/decred.go | 2 +- politeiad/testpoliteiad/decred.go | 73 + politeiad/testpoliteiad/testpoliteiad.go | 33 +- politeiawww/events.go | 6 +- politeiawww/proposals.go | 7 +- politeiawww/proposals_test.go | 3737 +++++++++++++++------- politeiawww/testing.go | 494 ++- 7 files changed, 3237 insertions(+), 1115 deletions(-) diff --git a/politeiad/cache/testcache/decred.go b/politeiad/cache/testcache/decred.go index b15cd6fac..a3f304869 100644 --- a/politeiad/cache/testcache/decred.go +++ b/politeiad/cache/testcache/decred.go @@ -223,7 +223,7 @@ func (c *testcache) findLinkedFrom(token string) ([]string, error) { // provided token. for _, allVersions := range c.records { // Get the latest version of the proposal - r := allVersions[string(len(allVersions))] + r := allVersions[strconv.Itoa(len(allVersions))] // Extract LinkTo from the ProposalMetadata file for _, f := range r.Files { diff --git a/politeiad/testpoliteiad/decred.go b/politeiad/testpoliteiad/decred.go index 03061f03a..50f144482 100644 --- a/politeiad/testpoliteiad/decred.go +++ b/politeiad/testpoliteiad/decred.go @@ -93,11 +93,84 @@ func (p *TestPoliteiad) startVote(payload string) (string, error) { return string(svrb), nil } +func (p *TestPoliteiad) startVoteRunoff(payload string) (string, error) { + svr, err := decred.DecodeStartVoteRunoff([]byte(payload)) + if err != nil { + return "", err + } + + p.Lock() + defer p.Unlock() + + // Store authorize votes + avReply := make(map[string]decred.AuthorizeVoteReply) + for _, av := range svr.AuthorizeVotes { + r, err := p.record(av.Token) + if err != nil { + return "", err + } + // Fill client data + s := p.identity.SignMessage([]byte(av.Signature)) + av.Version = decred.VersionAuthorizeVote + av.Receipt = hex.EncodeToString(s[:]) + av.Timestamp = time.Now().Unix() + av.Version = decred.VersionAuthorizeVote + + // Store + _, ok := p.authorizeVotes[av.Token] + if !ok { + p.authorizeVotes[av.Token] = make(map[string]decred.AuthorizeVote) + } + p.authorizeVotes[av.Token][r.Version] = av + + // Prepare response + avr := decred.AuthorizeVoteReply{ + Action: av.Action, + RecordVersion: r.Version, + Receipt: av.Receipt, + Timestamp: av.Timestamp, + } + avReply[av.Token] = avr + } + + // Store start votes + svReply := decred.StartVoteReply{} + for _, sv := range svr.StartVotes { + sv.Version = decred.VersionStartVote + p.startVotes[sv.Vote.Token] = sv + // Prepare response + endHeight := bestBlock + sv.Vote.Duration + svReply.Version = decred.VersionStartVoteReply + svReply.StartBlockHeight = strconv.FormatUint(uint64(bestBlock), 10) + svReply.EndHeight = strconv.FormatUint(uint64(endHeight), 10) + svReply.EligibleTickets = []string{} + } + + // Store start vote runoff + p.startVotesRunoff[svr.Token] = *svr + + response := decred.StartVoteRunoffReply{ + AuthorizeVoteReplies: avReply, + StartVoteReply: svReply, + } + + p.startVotesRunoffReplies[svr.Token] = response + + svrReply, err := decred.EncodeStartVoteRunoffReply(response) + if err != nil { + return "", err + } + + return string(svrReply), nil +} + // decredExec executes the passed in plugin command. func (p *TestPoliteiad) decredExec(pc v1.PluginCommand) (string, error) { switch pc.Command { case decred.CmdStartVote: return p.startVote(pc.Payload) + case decred.CmdStartVoteRunoff: + return p.startVoteRunoff(pc.Payload) case decred.CmdAuthorizeVote: return p.authorizeVote(pc.Payload) case decred.CmdBestBlock: diff --git a/politeiad/testpoliteiad/testpoliteiad.go b/politeiad/testpoliteiad/testpoliteiad.go index c76296bfc..eb7a87d1f 100644 --- a/politeiad/testpoliteiad/testpoliteiad.go +++ b/politeiad/testpoliteiad/testpoliteiad.go @@ -41,6 +41,7 @@ type TestPoliteiad struct { URL string // Base url of form http://ipaddr:port PublicIdentity *identity.PublicIdentity + FullIdentity *identity.FullIdentity identity *identity.FullIdentity server *httptest.Server @@ -48,9 +49,12 @@ type TestPoliteiad struct { records map[string]map[string]v1.Record // [token][version]Record // Decred plugin - authorizeVotes map[string]map[string]decred.AuthorizeVote // [token][version]AuthorizeVote - startVotes map[string]decred.StartVoteV2 // [token]StartVote - startVoteReplies map[string]decred.StartVoteReply // [token]StartVoteReply + authorizeVotes map[string]map[string]decred.AuthorizeVote // [token][version]AuthorizeVote + startVotes map[string]decred.StartVoteV2 // [token]StartVote + startVoteReplies map[string]decred.StartVoteReply // [token]StartVoteReply + startVotesRunoff map[string]decred.StartVoteRunoff // [token]StartVoteRunoff + startVotesRunoffReplies map[string]decred.StartVoteRunoffReply // [token]StartVoteRunoffReply + } func respondWithUserError(w http.ResponseWriter, @@ -173,17 +177,17 @@ func (p *TestPoliteiad) handleNewRecord(w http.ResponseWriter, r *http.Request) return } - merkle, err := merkleRoot(t.Files) + mr, err := merkleRoot(t.Files) if err != nil { util.RespondWithJSON(w, http.StatusInternalServerError, err) return } token := hex.EncodeToString(tokenb) - sig := p.identity.SignMessage([]byte(merkle + token)) + sig := p.identity.SignMessage([]byte(mr + token)) resp := p.identity.SignMessage(challenge) cr := v1.CensorshipRecord{ - Merkle: merkle, + Merkle: mr, Token: token, Signature: hex.EncodeToString(sig[:]), } @@ -481,13 +485,16 @@ func New(t *testing.T, c cache.Cache) *TestPoliteiad { // Init context p := TestPoliteiad{ - PublicIdentity: &id.Public, - identity: id, - cache: c, - records: make(map[string]map[string]v1.Record), - authorizeVotes: make(map[string]map[string]decred.AuthorizeVote), - startVotes: make(map[string]decred.StartVoteV2), - startVoteReplies: make(map[string]decred.StartVoteReply), + FullIdentity: id, + PublicIdentity: &id.Public, + identity: id, + cache: c, + records: make(map[string]map[string]v1.Record), + authorizeVotes: make(map[string]map[string]decred.AuthorizeVote), + startVotes: make(map[string]decred.StartVoteV2), + startVoteReplies: make(map[string]decred.StartVoteReply), + startVotesRunoff: make(map[string]decred.StartVoteRunoff), + startVotesRunoffReplies: make(map[string]decred.StartVoteRunoffReply), } // Setup routes diff --git a/politeiawww/events.go b/politeiawww/events.go index 4e4a83aa7..956a9651d 100644 --- a/politeiawww/events.go +++ b/politeiawww/events.go @@ -535,11 +535,7 @@ func (e *EventManager) _register(eventType EventT, listenerToAdd chan interface{ e.Listeners = make(map[EventT][]chan interface{}) } - if _, ok := e.Listeners[eventType]; ok { - e.Listeners[eventType] = append(e.Listeners[eventType], listenerToAdd) - } else { - e.Listeners[eventType] = []chan interface{}{listenerToAdd} - } + e.Listeners[eventType] = append(e.Listeners[eventType], listenerToAdd) } // _unregister removes the given listener channel for the given event type. diff --git a/politeiawww/proposals.go b/politeiawww/proposals.go index a65118a8d..c421cd5fe 100644 --- a/politeiawww/proposals.go +++ b/politeiawww/proposals.go @@ -2442,7 +2442,7 @@ func validateAuthorizeVote(av www.AuthorizeVote, u user.User, pr www.ProposalRec return nil } -// validateAuthorizeVoteRunoff validates the authorize vote for a proposal that +// validateAuthorizeVoteStandard validates the authorize vote for a proposal that // is participating in a standard vote. A UserError is returned if any of the // validation fails. func validateAuthorizeVoteStandard(av www.AuthorizeVote, u user.User, pr www.ProposalRecord, vs www.VoteSummary) error { @@ -2732,7 +2732,6 @@ func validateStartVoteStandard(sv www2.StartVote, u user.User, pr www.ProposalRe } // The remaining validation is specific to a VoteTypeStandard. - switch { case sv.Vote.Type != www2.VoteTypeStandard: // Not a standard vote @@ -2826,7 +2825,7 @@ func validateStartVoteRunoff(sv www2.StartVote, u user.User, pr www.ProposalReco case !isRFPSubmission(pr): // The proposal is not an RFP submission - e := fmt.Sprintf("%v in not an rfp submission", token) + e := fmt.Sprintf("%v is not an rfp submission", token) return www.UserError{ ErrorCode: www.ErrorStatusWrongProposalType, ErrorContext: []string{e}, @@ -3021,7 +3020,7 @@ func (p *politeiawww) processStartVoteRunoffV2(sv www2.StartVoteRunoff, u *user. } } if len(auths) == 0 { - e := fmt.Sprintf("start votes and authorize votes cannot be empty") + e := "start votes and authorize votes cannot be empty" return nil, www.UserError{ ErrorCode: www.ErrorStatusInvalidRunoffVote, ErrorContext: []string{e}, diff --git a/politeiawww/proposals_test.go b/politeiawww/proposals_test.go index 3dc669e6f..07168e9bd 100644 --- a/politeiawww/proposals_test.go +++ b/politeiawww/proposals_test.go @@ -5,27 +5,15 @@ package main import ( - "bytes" - "crypto/sha256" - "encoding/base64" "encoding/hex" - "encoding/json" "fmt" - "image" - "image/color" - "image/png" - "math/rand" - "net/http" + "reflect" "strconv" "testing" "time" - "github.com/decred/dcrtime/merkle" "github.com/decred/politeia/decredplugin" - "github.com/decred/politeia/mdstream" pd "github.com/decred/politeia/politeiad/api/v1" - "github.com/decred/politeia/politeiad/api/v1/identity" - "github.com/decred/politeia/politeiad/api/v1/mime" "github.com/decred/politeia/politeiad/testpoliteiad" www "github.com/decred/politeia/politeiawww/api/www/v1" www2 "github.com/decred/politeia/politeiawww/api/www/v2" @@ -33,385 +21,11 @@ import ( "github.com/decred/politeia/util" ) -// createFilePNG creates a File that contains a png image. The png image is -// blank by default but can be filled in with random rgb colors by setting the -// addColor parameter to true. The png without color will be ~3kB. The png -// with color will be ~2MB. -func createFilePNG(t *testing.T, addColor bool) *www.File { - t.Helper() - - b := new(bytes.Buffer) - img := image.NewRGBA(image.Rect(0, 0, 1000, 500)) - - // Fill in the pixels with random rgb colors in order to - // increase the size of the image. This is used to create an - // image that exceeds the maximum image size policy. - if addColor { - r := rand.New(rand.NewSource(255)) - for y := 0; y < img.Bounds().Max.Y-1; y++ { - for x := 0; x < img.Bounds().Max.X-1; x++ { - a := uint8(r.Float32() * 255) - rgb := uint8(r.Float32() * 255) - img.SetRGBA(x, y, color.RGBA{rgb, rgb, rgb, a}) - } - } - } - - err := png.Encode(b, img) - if err != nil { - t.Fatalf("%v", err) - } - - // Generate a random name - r, err := util.Random(8) - if err != nil { - t.Fatalf("%v", err) - } - - return &www.File{ - Name: hex.EncodeToString(r) + ".png", - MIME: mime.DetectMimeType(b.Bytes()), - Digest: hex.EncodeToString(util.Digest(b.Bytes())), - Payload: base64.StdEncoding.EncodeToString(b.Bytes()), - } -} - -// createFileMD creates a File that contains a markdown file. The markdown -// file is filled with randomly generated data. -func createFileMD(t *testing.T, size int) *www.File { - t.Helper() - - var b bytes.Buffer - r, err := util.Random(size) - if err != nil { - t.Fatalf("%v", err) - } - b.WriteString(base64.StdEncoding.EncodeToString(r) + "\n") - - return &www.File{ - Name: www.PolicyIndexFilename, - MIME: http.DetectContentType(b.Bytes()), - Digest: hex.EncodeToString(util.Digest(b.Bytes())), - Payload: base64.StdEncoding.EncodeToString(b.Bytes()), - } -} - -// newFileRandomMD returns a File with the name index.md that contains random -// base64 text. -func newFileRandomMD(t *testing.T) www.File { - t.Helper() - - var b bytes.Buffer - // Add ten lines of random base64 text. - for i := 0; i < 10; i++ { - r, err := util.Random(32) - if err != nil { - t.Fatal(err) - } - b.WriteString(base64.StdEncoding.EncodeToString(r) + "\n") - } - - return www.File{ - Name: "index.md", - MIME: mime.DetectMimeType(b.Bytes()), - Digest: hex.EncodeToString(util.Digest(b.Bytes())), - Payload: base64.StdEncoding.EncodeToString(b.Bytes()), - } -} - -func proposalNameRandom(t *testing.T) string { - r, err := util.Random(www.PolicyMinProposalNameLength) - if err != nil { - t.Fatal(err) - } - return hex.EncodeToString(r) -} - -func newProposalMetadata(t *testing.T, name string) []www.Metadata { - if name == "" { - // Generate a random name if none was given - name = proposalNameRandom(t) - } - pm := www.ProposalMetadata{ - Name: name, - } - pmb, err := json.Marshal(pm) - if err != nil { - t.Fatal(err) - } - return []www.Metadata{ - { - Digest: hex.EncodeToString(util.Digest(pmb)), - Hint: www.HintProposalMetadata, - Payload: base64.StdEncoding.EncodeToString(pmb), - }, - } -} - -// createNewProposal computes the merkle root of the given files, signs the -// merkle root with the given identity then returns a NewProposal object. -func createNewProposal(t *testing.T, id *identity.FullIdentity, files []www.File, title string) *www.NewProposal { - t.Helper() - - // Setup metadata - metadata := newProposalMetadata(t, title) - - // Compute and sign merkle root - m := merkleRoot(t, files, metadata) - sig := id.SignMessage([]byte(m)) - - return &www.NewProposal{ - Files: files, - Metadata: metadata, - PublicKey: hex.EncodeToString(id.Public.Key[:]), - Signature: hex.EncodeToString(sig[:]), - } -} - -// merkleRoot returns a hex encoded merkle root of the passed in files and -// metadata. -func merkleRoot(t *testing.T, files []www.File, metadata []www.Metadata) string { - t.Helper() - - digests := make([]*[sha256.Size]byte, 0, len(files)) - for _, f := range files { - // Compute file digest - b, err := base64.StdEncoding.DecodeString(f.Payload) - if err != nil { - t.Fatalf("decode payload for file %v: %v", - f.Name, err) - } - digest := util.Digest(b) - - // Compare against digest that came with the file - d, ok := util.ConvertDigest(f.Digest) - if !ok { - t.Fatalf("invalid digest: file:%v digest:%v", - f.Name, f.Digest) - } - if !bytes.Equal(digest, d[:]) { - t.Fatalf("digests do not match for file %v", - f.Name) - } - - // Digest is valid - digests = append(digests, &d) - } - - for _, v := range metadata { - b, err := base64.StdEncoding.DecodeString(v.Payload) - if err != nil { - t.Fatalf("decode payload for metadata %v: %v", - v.Hint, err) - } - digest := util.Digest(b) - d, ok := util.ConvertDigest(v.Digest) - if !ok { - t.Fatalf("invalid digest: metadata:%v digest:%v", - v.Hint, v.Digest) - } - if !bytes.Equal(digest, d[:]) { - t.Fatalf("digests do not match for metadata %v", - v.Hint) - } - - // Digest is valid - digests = append(digests, &d) - } - - // Compute merkle root - return hex.EncodeToString(merkle.Root(digests)[:]) -} - -func newStartVote(t *testing.T, token string, proposalVersion uint32, id *identity.FullIdentity) www2.StartVote { - vote := www2.Vote{ - Token: token, - ProposalVersion: proposalVersion, - Type: www2.VoteTypeStandard, - Mask: 0x03, // bit 0 no, bit 1 yes - Duration: 2016, - QuorumPercentage: 20, - PassPercentage: 60, - Options: []www2.VoteOption{ - { - Id: "no", - Description: "Don't approve proposal", - Bits: 0x01, - }, - { - Id: "yes", - Description: "Approve proposal", - Bits: 0x02, - }, - }, - } - vb, err := json.Marshal(vote) - if err != nil { - t.Fatalf("marshal vote failed: %v %v", err, vote) - } - msg := hex.EncodeToString(util.Digest(vb)) - sig := id.SignMessage([]byte(msg)) - return www2.StartVote{ - Vote: vote, - PublicKey: hex.EncodeToString(id.Public.Key[:]), - Signature: hex.EncodeToString(sig[:]), - } -} - -func newStartVoteCmd(t *testing.T, token string, proposalVersion uint32, id *identity.FullIdentity) pd.PluginCommand { - sv := newStartVote(t, token, proposalVersion, id) - dsv := convertStartVoteV2ToDecred(sv) - payload, err := decredplugin.EncodeStartVoteV2(dsv) - if err != nil { - t.Fatal(err) - } - - challenge, err := util.Random(pd.ChallengeSize) - if err != nil { - t.Fatal(err) - } - - return pd.PluginCommand{ - Challenge: hex.EncodeToString(challenge), - ID: decredplugin.ID, - Command: decredplugin.CmdStartVote, - CommandID: decredplugin.CmdStartVote + " " + sv.Vote.Token, - Payload: string(payload), - } -} - -func newAuthorizeVote(token, version, action string, id *identity.FullIdentity) www.AuthorizeVote { - sig := id.SignMessage([]byte(token + version + action)) - return www.AuthorizeVote{ - Action: action, - Token: token, - Signature: hex.EncodeToString(sig[:]), - PublicKey: hex.EncodeToString(id.Public.Key[:]), - } -} - -func newAuthorizeVoteCmd(t *testing.T, token, version, action string, id *identity.FullIdentity) pd.PluginCommand { - av := newAuthorizeVote(token, version, action, id) - dav := convertAuthorizeVoteToDecred(av) - payload, err := decredplugin.EncodeAuthorizeVote(dav) - if err != nil { - t.Fatal(err) - } - - challenge, err := util.Random(pd.ChallengeSize) - if err != nil { - t.Fatal(err) - } - - return pd.PluginCommand{ - Challenge: hex.EncodeToString(challenge), - ID: decredplugin.ID, - Command: decredplugin.CmdAuthorizeVote, - CommandID: decredplugin.CmdAuthorizeVote + " " + av.Token, - Payload: string(payload), - } -} - -func newProposalRecord(t *testing.T, u *user.User, id *identity.FullIdentity, s www.PropStatusT) www.ProposalRecord { - t.Helper() - - f := newFileRandomMD(t) - files := []www.File{f} - name := proposalNameRandom(t) - metadata := newProposalMetadata(t, name) - m := merkleRoot(t, files, metadata) - sig := id.SignMessage([]byte(m)) - - var ( - publishedAt int64 - censoredAt int64 - abandonedAt int64 - changeMsg string - ) - - switch s { - case www.PropStatusCensored: - changeMsg = "did not adhere to guidelines" - censoredAt = time.Now().Unix() - case www.PropStatusPublic: - publishedAt = time.Now().Unix() - case www.PropStatusAbandoned: - changeMsg = "no activity" - publishedAt = time.Now().Unix() - abandonedAt = time.Now().Unix() - } - - tokenb, err := util.Random(pd.TokenSize) - if err != nil { - t.Fatal(err) - } - - // The token is typically generated in politeiad. This function - // generates the token locally to make setting up tests easier. - // The censorship record signature is left intentionally blank. - return www.ProposalRecord{ - Name: name, - State: convertPropStatusToState(s), - Status: s, - Timestamp: time.Now().Unix(), - UserId: u.ID.String(), - Username: u.Username, - PublicKey: u.PublicKey(), - Signature: hex.EncodeToString(sig[:]), - NumComments: 0, - Version: "1", - StatusChangeMessage: changeMsg, - PublishedAt: publishedAt, - CensoredAt: censoredAt, - AbandonedAt: abandonedAt, - Files: files, - Metadata: metadata, - CensorshipRecord: www.CensorshipRecord{ - Token: hex.EncodeToString(tokenb), - Merkle: m, - Signature: "", - }, - } -} - -func convertPropToPD(t *testing.T, p www.ProposalRecord) pd.Record { - t.Helper() - - // Attach ProposalMetadata as a politeiad file - files := convertPropFilesFromWWW(p.Files) - for _, v := range p.Metadata { - switch v.Hint { - case www.HintProposalMetadata: - files = append(files, convertFileFromMetadata(v)) - } - } - - // Create a ProposalGeneralV2 mdstream - md, err := mdstream.EncodeProposalGeneralV2( - mdstream.ProposalGeneralV2{ - Version: mdstream.VersionProposalGeneral, - Timestamp: time.Now().Unix(), - PublicKey: p.PublicKey, - Signature: p.Signature, - }) - if err != nil { - t.Fatal(err) - } - - mdStreams := []pd.MetadataStream{{ - ID: mdstream.IDProposalGeneral, - Payload: string(md), - }} - - return pd.Record{ - Status: convertPropStatusFromWWW(p.Status), - Timestamp: p.Timestamp, - Version: p.Version, - Metadata: mdStreams, - CensorshipRecord: convertPropCensorFromWWW(p.CensorshipRecord), - Files: files, - } -} +const ( + // Helper vote duration constants + minDuration = 2016 + maxDuration = 4032 +) func TestIsValidProposalName(t *testing.T) { tests := []struct { @@ -485,109 +99,546 @@ func TestIsValidProposalName(t *testing.T) { } } -func TestValidateProposal(t *testing.T) { - // Setup politeiawww and a test user +func TestIsProposalAuthor(t *testing.T) { p, cleanup := newTestPoliteiawww(t) defer cleanup() usr, id := newUser(t, p, true, false) + notAuthorUser, _ := newUser(t, p, true, false) - // Create test data - md := createFileMD(t, 8) - png := createFilePNG(t, false) - np := createNewProposal(t, id, []www.File{*md, *png}, "") + proposal := newProposalRecord(t, usr, id, www.PropStatusPublic) - // Invalid signature - propInvalidSig := createNewProposal(t, id, []www.File{*md}, "") - propInvalidSig.Signature = "abc" + var tests = []struct { + name string + proposal www.ProposalRecord + usr *user.User + want bool + }{ + { + "is proposal author", + proposal, + usr, + true, + }, + { + "is not proposal author", + proposal, + notAuthorUser, + false, + }, + } - // Signature is valid but incorrect - propBadSig := createNewProposal(t, id, []www.File{*md}, "") - propBadSig.Signature = np.Signature + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + isAuthor := isProposalAuthor(test.proposal, *test.usr) + if isAuthor != test.want { + t.Errorf("got %v, want %v", isAuthor, test.want) + } + }) + } +} - // No files - propNoFiles := createNewProposal(t, id, []www.File{}, "") +func TestIsRFP(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() - // Invalid markdown filename - mdBadFilename := *md - mdBadFilename.Name = "bad_filename.md" - propBadFilename := createNewProposal(t, id, []www.File{mdBadFilename}, "") + usr, id := newUser(t, p, true, false) - // Duplicate filenames - propDupFiles := createNewProposal(t, id, []www.File{*md, *png, *png}, "") + rfpProposal := newProposalRecord(t, usr, id, www.PropStatusPublic) + rfpProposalSubmission := newProposalRecord(t, usr, id, www.PropStatusPublic) - // Too many markdown files. We need one correctly named md - // file and the rest must have their names changed so that we - // don't get a duplicate filename error. - files := make([]www.File, 0, www.PolicyMaxMDs+1) - files = append(files, *md) - for i := 0; i < www.PolicyMaxMDs; i++ { - m := *md - m.Name = fmt.Sprintf("%v.md", i) - files = append(files, m) + linkFrom := []string{rfpProposalSubmission.CensorshipRecord.Token} + linkTo := rfpProposal.CensorshipRecord.Token + linkBy := time.Now().Add(time.Hour * 24 * 30).Unix() + + rfpSubmissions := []*www.ProposalRecord{&rfpProposalSubmission} + + makeProposalRFP(t, &rfpProposal, linkFrom, linkBy) + makeProposalRFPSubmissions(t, rfpSubmissions, linkTo) + + var tests = []struct { + name string + proposal www.ProposalRecord + want bool + }{ + { + "is RFP proposal", + rfpProposal, + true, + }, + { + "is not RFP proposal", + rfpProposalSubmission, + false, + }, } - propMaxMDFiles := createNewProposal(t, id, files, "") - // Too many image files. All of their names must be different - // so that we don't get a duplicate filename error. - files = make([]www.File, 0, www.PolicyMaxImages+2) - files = append(files, *md) - for i := 0; i <= www.PolicyMaxImages; i++ { - p := *png - p.Name = fmt.Sprintf("%v.png", i) - files = append(files, p) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + isRFP := isRFP(test.proposal) + if isRFP != test.want { + t.Errorf("got %v, want %v", isRFP, test.want) + } + }) } - propMaxImages := createNewProposal(t, id, files, "") +} - // Markdown file too large - mdLarge := createFileMD(t, www.PolicyMaxMDSize) - propMDLarge := createNewProposal(t, id, []www.File{*mdLarge, *png}, "") +func TestIsRFPSubmission(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() - // Image too large - pngLarge := createFilePNG(t, true) - propImageLarge := createNewProposal(t, id, []www.File{*md, *pngLarge}, "") + usr, id := newUser(t, p, true, false) - // Invalid proposal title - mdBadTitle := createFileMD(t, 8) - propBadTitle := createNewProposal(t, id, - []www.File{*mdBadTitle}, "{invalid-title}") + rfpProposal := newProposalRecord(t, usr, id, www.PropStatusPublic) + rfpProposalSubmission := newProposalRecord(t, usr, id, www.PropStatusPublic) + + linkFrom := []string{rfpProposalSubmission.CensorshipRecord.Token} + linkTo := rfpProposal.CensorshipRecord.Token + linkBy := time.Now().Add(time.Hour * 24 * 30).Unix() + rfpSubmissions := []*www.ProposalRecord{&rfpProposalSubmission} + + makeProposalRFP(t, &rfpProposal, linkFrom, linkBy) + makeProposalRFPSubmissions(t, rfpSubmissions, linkTo) - // Setup test cases var tests = []struct { - name string - newProposal www.NewProposal - user *user.User - want error + name string + proposal www.ProposalRecord + want bool }{ { - "correct proposal", - *np, - usr, - nil, + "is RFP submission", + rfpProposalSubmission, + true, }, { - "invalid signature", - *propInvalidSig, - usr, - www.UserError{ - ErrorCode: www.ErrorStatusInvalidSignature, - }, + "is not RFP submission", + rfpProposal, + false, }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + isRFPSubmission := isRFPSubmission(test.proposal) + if isRFPSubmission != test.want { + t.Errorf("got %v, want %v", isRFPSubmission, test.want) + } + }) + } +} + +func TestVoteIsApproved(t *testing.T) { + yes := decredplugin.VoteOptionIDApprove + no := decredplugin.VoteOptionIDReject + emptyResults := []www.VoteOptionResult{} + badQuorumResults := []www.VoteOptionResult{ + newVoteOptionResult(t, no, "not approve", 1, 0), + newVoteOptionResult(t, yes, "approve", 2, 1), + } + badPassPercentageResults := []www.VoteOptionResult{ + newVoteOptionResult(t, no, "not approve", 1, 8), + newVoteOptionResult(t, yes, "approve", 2, 2), + } + approvedResults := []www.VoteOptionResult{ + newVoteOptionResult(t, no, "not approve", 1, 2), + newVoteOptionResult(t, yes, "approve", 2, 8), + } + vsVoteNotFinished := newVoteSummary(t, www.PropVoteStatusAuthorized, + emptyResults) + vsQuorumNotMet := newVoteSummary(t, www.PropVoteStatusFinished, + badQuorumResults) + vsPassPercentageNotMet := newVoteSummary(t, www.PropVoteStatusFinished, + badPassPercentageResults) + vsApproved := newVoteSummary(t, www.PropVoteStatusFinished, approvedResults) + + var tests = []struct { + name string + summary www.VoteSummary + want bool + }{ { - "incorrect signature", - *propBadSig, - usr, - www.UserError{ - ErrorCode: www.ErrorStatusInvalidSignature, - }, + "vote not finished", + vsVoteNotFinished, + false, }, { - "no files", - *propNoFiles, - usr, - www.UserError{ - ErrorCode: www.ErrorStatusProposalMissingFiles, - }, + "vote did not pass quorum", + vsQuorumNotMet, + false, + }, + { + "vote did not meet pass percentage", + vsPassPercentageNotMet, + false, + }, + { + "vote is approved", + vsApproved, + true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := voteIsApproved(test.summary) + if got != test.want { + t.Errorf("got %v, want %v", got, test.want) + } + }) + } +} + +func TestValidateProposalMetadata(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + usr, id := newUser(t, p, true, false) + + // Helper variables + no := decredplugin.VoteOptionIDReject + yes := decredplugin.VoteOptionIDApprove + public := www.PropStatusPublic + linkFrom := []string{} + linkBy := time.Now().Add(time.Hour * 24 * 30).Unix() + invalidName := "bad" + validName := "valid name" + invalidToken := "abcdfe" + randomToken, err := util.Random(pd.TokenSize) + if err != nil { + t.Fatal(err) + } + rToken := hex.EncodeToString(randomToken) + + // Public proposal + proposal := newProposalRecord(t, usr, id, public) + token := proposal.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, proposal)) + + // RFP proposal approved + rfpProposal := newProposalRecord(t, usr, id, public) + rfpToken := rfpProposal.CensorshipRecord.Token + makeProposalRFP(t, &rfpProposal, linkFrom, linkBy) + d.AddRecord(t, convertPropToPD(t, rfpProposal)) + + // RFP proposal not approved + rfpProposalNotApproved := newProposalRecord(t, usr, id, public) + rfpTokenNotApproved := rfpProposalNotApproved.CensorshipRecord.Token + makeProposalRFP(t, &rfpProposalNotApproved, linkFrom, linkBy) + d.AddRecord(t, convertPropToPD(t, rfpProposalNotApproved)) + + // RFP bad linkBy expired timestamp + rfpBadLinkBy := newProposalRecord(t, usr, id, public) + rfpBadLinkByToken := rfpBadLinkBy.CensorshipRecord.Token + badLinkBy := int64(1351700038) + makeProposalRFP(t, &rfpBadLinkBy, linkFrom, badLinkBy) + d.AddRecord(t, convertPropToPD(t, rfpBadLinkBy)) + + // RFP bad state + rfpBadState := newProposalRecord(t, usr, id, www.PropStatusNotReviewed) + rfpBadStateToken := rfpBadState.CensorshipRecord.Token + makeProposalRFP(t, &rfpBadState, linkFrom, linkBy) + d.AddRecord(t, convertPropToPD(t, rfpBadState)) + + // Approved VoteSummary for proposal + approved := []www.VoteOptionResult{ + newVoteOptionResult(t, no, "not approve", 1, 2), + newVoteOptionResult(t, yes, "approve", 2, 8), + } + vsApproved := newVoteSummary(t, www.PropVoteStatusFinished, approved) + p.voteSummarySet(rfpToken, vsApproved) + p.voteSummarySet(rfpBadLinkByToken, vsApproved) + + // Not approved VoteSummary for proposal + notApproved := []www.VoteOptionResult{ + newVoteOptionResult(t, no, "not approve", 1, 8), + newVoteOptionResult(t, yes, "approve", 2, 2), + } + vsNotApproved := newVoteSummary(t, www.PropVoteStatusFinished, notApproved) + p.voteSummarySet(rfpTokenNotApproved, vsNotApproved) + p.voteSummarySet(rfpBadLinkByToken, vsApproved) + p.voteSummarySet(rfpBadStateToken, vsApproved) + + // Metadatas to validate and test + _, mdInvalidName := newProposalMetadata(t, invalidName, "", 0) + // LinkTo validations + _, mdInvalidLinkTo := newProposalMetadata(t, validName, invalidToken, 0) + _, mdProposalNotFound := newProposalMetadata(t, validName, rToken, 0) + _, mdProposalNotRFP := newProposalMetadata(t, validName, token, 0) + _, mdProposalNotApproved := newProposalMetadata(t, validName, + rfpTokenNotApproved, 0) + _, mdProposalBadLinkBy := newProposalMetadata(t, validName, + rfpBadLinkByToken, 0) + _, mdProposalBadState := newProposalMetadata(t, validName, + rfpBadStateToken, 0) + _, mdProposalBothRFP := newProposalMetadata(t, validName, rfpToken, + time.Now().Unix()) + // LinkBy validations + _, mdLinkByMin := newProposalMetadata(t, validName, "", 100) + _, mdLinkByMax := newProposalMetadata(t, validName, "", + time.Now().Unix()+7777000) + linkByMinError := fmt.Sprintf("linkby period cannot be shorter than %v"+ + " seconds", p.linkByPeriodMin()) + linkByMaxError := fmt.Sprintf("linkby period cannot be greater than %v"+ + " seconds", p.linkByPeriodMax()) + _, mdSuccess := newProposalMetadata(t, validName, rfpToken, 0) + + var tests = []struct { + name string + metadata www.ProposalMetadata + wantError error + }{ + { + "invalid proposal name", + mdInvalidName, + www.UserError{ + ErrorCode: www.ErrorStatusProposalInvalidTitle, + ErrorContext: []string{createProposalNameRegex()}, + }, + }, + { + "invalid linkTo bad token", + mdInvalidLinkTo, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"invalid token"}, + }, + }, + { + "invalid linkTo token proposal not found", + mdProposalNotFound, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + }, + }, + { + "invalid linkTo not a RFP proposal", + mdProposalNotRFP, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"linkto proposal is not an rfp"}, + }, + }, + { + "invalid linkTo RFP proposal vote not approved", + mdProposalNotApproved, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"rfp proposal vote did not pass"}, + }, + }, + { + "invalid linkTo proposal deadline is expired", + mdProposalBadLinkBy, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"linkto proposal deadline is expired"}, + }, + }, + { + "invalid linkTo proposal is not vetted", + mdProposalBadState, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"linkto proposal is not vetted"}, + }, + }, + { + "invalid linkTo rfp proposal linked to another rfp", + mdProposalBothRFP, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkTo, + ErrorContext: []string{"an rfp cannot link to an rfp"}, + }, + }, + { + "invalid linkBy shorter than min", + mdLinkByMin, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkBy, + ErrorContext: []string{linkByMinError}, + }, + }, + { + "invalid linkBy greather than max", + mdLinkByMax, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidLinkBy, + ErrorContext: []string{linkByMaxError}, + }, + }, + { + "validation success", + mdSuccess, + nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := p.validateProposalMetadata(test.metadata) + + if err != nil { + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.wantError.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.wantError.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } + + } + + }) + } +} + +func TestValidateProposal(t *testing.T) { + // Setup politeiawww and a test user + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + usr, id := newUser(t, p, true, false) + + // Create test data + md := createFileMD(t, 8) + png := createFilePNG(t, false) + np := createNewProposal(t, id, []www.File{*md, *png}, "") + + // Invalid signature + propInvalidSig := createNewProposal(t, id, []www.File{*md}, "") + propInvalidSig.Signature = "abc" + + // Signature is valid but incorrect + propBadSig := createNewProposal(t, id, []www.File{*md}, "") + propBadSig.Signature = np.Signature + + // No files + propNoFiles := createNewProposal(t, id, []www.File{}, "") + + // Invalid markdown filename + mdBadFilename := *md + mdBadFilename.Name = "bad_filename.md" + propBadFilename := createNewProposal(t, id, []www.File{mdBadFilename}, "") + + // Duplicate filenames + propDupFiles := createNewProposal(t, id, []www.File{*md, *png, *png}, "") + + // Too many markdown files. We need one correctly named md + // file and the rest must have their names changed so that we + // don't get a duplicate filename error. + files := make([]www.File, 0, www.PolicyMaxMDs+1) + files = append(files, *md) + for i := 0; i < www.PolicyMaxMDs; i++ { + m := *md + m.Name = fmt.Sprintf("%v.md", i) + files = append(files, m) + } + propMaxMDFiles := createNewProposal(t, id, files, "") + + // Too many image files. All of their names must be different + // so that we don't get a duplicate filename error. + files = make([]www.File, 0, www.PolicyMaxImages+2) + files = append(files, *md) + for i := 0; i <= www.PolicyMaxImages; i++ { + p := *png + p.Name = fmt.Sprintf("%v.png", i) + files = append(files, p) + } + propMaxImages := createNewProposal(t, id, files, "") + + // Markdown file too large + mdLarge := createFileMD(t, www.PolicyMaxMDSize) + propMDLarge := createNewProposal(t, id, []www.File{*mdLarge, *png}, "") + + // Image too large + pngLarge := createFilePNG(t, true) + propImageLarge := createNewProposal(t, id, []www.File{*md, *pngLarge}, "") + + // Invalid proposal title + mdBadTitle := createFileMD(t, 8) + propBadTitle := createNewProposal(t, id, + []www.File{*mdBadTitle}, "{invalid-title}") + + // Empty file payload + propEmptyFile := createNewProposal(t, id, []www.File{*md}, "") + emptyFile := createFileMD(t, 8) + emptyFile.Payload = "" + propEmptyFile.Files = []www.File{*emptyFile} + + // Invalid file digest + propInvalidDigest := createNewProposal(t, id, []www.File{*md}, "") + invalidDigestFile := createFilePNG(t, false) + invalidDigestFile.Digest = "" + propInvalidDigest.Files = append(propInvalidDigest.Files, *invalidDigestFile) + + // Invalid MIME type + propInvalidMIME := createNewProposal(t, id, []www.File{*md}, "") + invalidMIMEFile := createFilePNG(t, false) + invalidMIMEFile.MIME = "image/jpeg" + propInvalidMIME.Files = append(propInvalidMIME.Files, *invalidMIMEFile) + + // Invalid metadata + propInvalidMetadata := createNewProposal(t, id, []www.File{*md}, "") + invalidMd := www.Metadata{ + Digest: "", + Payload: "", + Hint: www.HintProposalMetadata, + } + propInvalidMetadata.Metadata = append(propInvalidMetadata.Metadata, invalidMd) + + // Missing metadata + propMissingMetadata := createNewProposal(t, id, []www.File{*md}, "") + propMissingMetadata.Metadata = nil + + // Setup test cases + var tests = []struct { + name string + newProposal www.NewProposal + user *user.User + want error + }{ + { + "correct proposal", + *np, + usr, + nil, + }, + { + "invalid signature", + *propInvalidSig, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSignature, + }, + }, + { + "incorrect signature", + *propBadSig, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSignature, + }, + }, + { + "missing files", + *propNoFiles, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusProposalMissingFiles, + }, }, { "bad md filename", @@ -605,6 +656,46 @@ func TestValidateProposal(t *testing.T) { ErrorCode: www.ErrorStatusProposalDuplicateFilenames, }, }, + { + "empty file payload", + *propEmptyFile, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidBase64, + }, + }, + { + "invalid file digest", + *propInvalidDigest, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidFileDigest, + }, + }, + { + "invalid file MIME type", + *propInvalidMIME, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidMIMEType, + }, + }, + { + "invalid metadata", + *propInvalidMetadata, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusMetadataInvalid, + }, + }, + { + "missing metadata", + *propMissingMetadata, + usr, + www.UserError{ + ErrorCode: www.ErrorStatusMetadataMissing, + }, + }, { "too may md files", *propMaxMDFiles, @@ -660,822 +751,2292 @@ func TestValidateProposal(t *testing.T) { } } -func TestFilterProposals(t *testing.T) { - // Test proposal page size. Only a single page of proposals - // should be returned. - t.Run("proposal page size", func(t *testing.T) { - pq := proposalsFilter{ - StateMap: map[www.PropStateT]bool{ - www.PropStateVetted: true, - }, - } +func TestValidateAuthorizeVote(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() - // We use simplified userIDs, timestamps, and censorship - // record tokens. This is ok since filterProps() does not - // check the validity of the data. - c := www.ProposalListPageSize + 5 - propsPageTest := make([]www.ProposalRecord, 0, c) - for i := 1; i <= c; i++ { - propsPageTest = append(propsPageTest, www.ProposalRecord{ - State: www.PropStateVetted, - UserId: strconv.Itoa(i), - Timestamp: int64(i), - CensorshipRecord: www.CensorshipRecord{ - Token: strconv.Itoa(i), - }, - }) - } + d := newTestPoliteiad(t, p) + defer d.Close() - out := filterProps(pq, propsPageTest) - if len(out) != www.ProposalListPageSize { - t.Errorf("got %v, want %v", len(out), www.ProposalListPageSize) - } - }) + usr, id := newUser(t, p, true, false) + _, id2 := newUser(t, p, true, false) - // Create data for test table. We use simplified userIDs, - // timestamps, and censorship record tokens. This is ok since - // filterProps() does not check the validity of the data. - props := make(map[int]*www.ProposalRecord, 5) - for i := 1; i <= 5; i++ { - props[i] = &www.ProposalRecord{ - State: www.PropStateVetted, - UserId: strconv.Itoa(i), - Timestamp: int64(i), - CensorshipRecord: www.CensorshipRecord{ - Token: strconv.Itoa(i), - }, - } - } + authorize := decredplugin.AuthVoteActionAuthorize + revoke := decredplugin.AuthVoteActionRevoke - // Change the State of a few proposals so that they are not - // all the same. - props[2].State = www.PropStateUnvetted - props[4].State = www.PropStateUnvetted + // Public proposal + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) + + // Authorize vote + av := newAuthorizeVote(t, token, prop.Version, authorize, id) + + // Revoke vote + rv := newAuthorizeVote(t, token, prop.Version, revoke, id) + + // Wrong status proposal + propUnreviewed := newProposalRecord(t, usr, id, www.PropStatusNotReviewed) + d.AddRecord(t, convertPropToPD(t, prop)) + + // Invalid signing key + avInvalid := newAuthorizeVote(t, token, prop.Version, authorize, id) + avInvalid.PublicKey = hex.EncodeToString(id2.Public.Key[:]) + + // Invalid vote action + avInvalidAct := newAuthorizeVote(t, token, prop.Version, "bad", id) - // Setup tests var tests = []struct { - name string - req proposalsFilter - input []www.ProposalRecord - want []www.ProposalRecord + name string + av www.AuthorizeVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary + want error }{ - {"filter by State", - proposalsFilter{ - StateMap: map[www.PropStateT]bool{ - www.PropStateUnvetted: true, - }, - }, - []www.ProposalRecord{ - *props[1], *props[2], *props[3], *props[4], *props[5], - }, - []www.ProposalRecord{ - *props[4], *props[2], + { + "invalid signing key", + avInvalid, + *usr, + prop, + www.VoteSummary{}, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSigningKey, }, }, - - {"filter by UserID", - proposalsFilter{ - UserID: "1", - StateMap: map[www.PropStateT]bool{ - www.PropStateVetted: true, - }, - }, - []www.ProposalRecord{ - *props[1], *props[2], *props[3], *props[4], *props[5], - }, - []www.ProposalRecord{ - *props[1], + { + "wrong proposal status", + av, + *usr, + propUnreviewed, + www.VoteSummary{}, + www.UserError{ + ErrorCode: www.ErrorStatusWrongStatus, }, }, - - {"filter by Before", - proposalsFilter{ - Before: props[3].CensorshipRecord.Token, - StateMap: map[www.PropStateT]bool{ - www.PropStateUnvetted: true, - www.PropStateVetted: true, - }, - }, - []www.ProposalRecord{ - *props[1], *props[2], *props[3], *props[4], *props[5], + { + "vote has already started", + av, + *usr, + prop, + www.VoteSummary{ + EndHeight: 1552, }, - []www.ProposalRecord{ - *props[5], *props[4], + www.UserError{ + ErrorCode: www.ErrorStatusWrongVoteStatus, }, }, - - {"filter by After", - proposalsFilter{ - After: props[3].CensorshipRecord.Token, - StateMap: map[www.PropStateT]bool{ - www.PropStateUnvetted: true, - www.PropStateVetted: true, - }, + { + "invalid auth vote action", + avInvalidAct, + *usr, + prop, + www.VoteSummary{}, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidAuthVoteAction, }, - []www.ProposalRecord{ - *props[1], *props[2], *props[3], *props[4], *props[5], + }, + { + "vote has already been authorized", + av, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, - []www.ProposalRecord{ - *props[2], *props[1], + www.UserError{ + ErrorCode: www.ErrorStatusVoteAlreadyAuthorized, }, }, - - {"unsorted proposals", - proposalsFilter{ - StateMap: map[www.PropStateT]bool{ - www.PropStateUnvetted: true, - www.PropStateVetted: true, - }, + { + "cannot revoke vote that has not been authorized", + rv, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusNotAuthorized, }, - []www.ProposalRecord{ - *props[3], *props[4], *props[1], *props[5], *props[2], + www.UserError{ + ErrorCode: www.ErrorStatusVoteNotAuthorized, }, - []www.ProposalRecord{ - *props[5], *props[4], *props[3], *props[2], *props[1], + }, + { + "valid authorize vote", + av, + *usr, + prop, + www.VoteSummary{}, + nil, + }, + { + "valid revoke vote", + rv, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + nil, }, } - // Run tests + // Run test cases for _, test := range tests { t.Run(test.name, func(t *testing.T) { - out := filterProps(test.req, test.input) - - // Pull out the tokens to make it easier to view the - // difference between want and got - want := make([]string, len(test.want)) - for _, p := range test.want { - want = append(want, p.CensorshipRecord.Token) - } - got := make([]string, len(out)) - for _, p := range out { - got = append(got, p.CensorshipRecord.Token) - } - - // Check if want and got are the same - if len(want) != len(got) { - goto fail - } - for i, w := range want { - if w != got[i] { - goto fail - } + err := validateAuthorizeVote(test.av, test.u, test.pr, test.vs) + got := errToStr(err) + want := errToStr(test.want) + if got != want { + t.Errorf("got %v, want %v", got, want) } - - // success; want and got are the same - return - - fail: - t.Errorf("got %v, want %v", got, want) }) } } -func TestProcessNewProposal(t *testing.T) { - // Setup test environment +func TestValidateAuthorizeVoteStandard(t *testing.T) { p, cleanup := newTestPoliteiawww(t) defer cleanup() - td := testpoliteiad.New(t, p.cache) - defer td.Close() + d := newTestPoliteiad(t, p) + defer d.Close() - p.cfg.RPCHost = td.URL - p.cfg.Identity = td.PublicIdentity + usr, id := newUser(t, p, true, false) + _, id2 := newUser(t, p, true, false) - // Create a user that has not paid their registration fee. - usrUnpaid, _ := newUser(t, p, true, false) + authorize := decredplugin.AuthVoteActionAuthorize - // Create a user that has paid their registration - // fee but does not have any proposal credits. - usrNoCredits, _ := newUser(t, p, true, false) - payRegistrationFee(t, p, usrNoCredits) + // RFP proposal + rfpProp := newProposalRecord(t, usr, id, www.PropStatusPublic) + rfpPropToken := rfpProp.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, rfpProp)) - // Create a user that has paid their registration - // fee and has purchased proposal credits. - usrValid, id := newUser(t, p, true, false) - payRegistrationFee(t, p, usrValid) - addProposalCredits(t, p, usrValid, 10) + // RFP proposal submission + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + makeProposalRFPSubmissions(t, []*www.ProposalRecord{&prop}, rfpPropToken) + d.AddRecord(t, convertPropToPD(t, prop)) - // Create a NewProposal - f := newFileRandomMD(t) - np := createNewProposal(t, id, []www.File{f}, "") + // Public proposal submission wrong author + prop2 := newProposalRecord(t, usr, id, www.PropStatusPublic) + prop2.PublicKey = hex.EncodeToString(id2.Public.Key[:]) + d.AddRecord(t, convertPropToPD(t, prop2)) - // Invalid proposal - propInvalid := createNewProposal(t, id, []www.File{f}, "") - propInvalid.Signature = "" + // Valid proposal vote auth + propValid := newProposalRecord(t, usr, id, www.PropStatusPublic) + d.AddRecord(t, convertPropToPD(t, propValid)) + + // Authorize vote + av := newAuthorizeVote(t, token, prop.Version, authorize, id) - // Setup tests var tests = []struct { name string - np *www.NewProposal - usr *user.User + av www.AuthorizeVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary want error }{ - {"unpaid registration fee", np, usrUnpaid, - www.UserError{ - ErrorCode: www.ErrorStatusUserNotPaid, - }}, - - {"no proposal credits", np, usrNoCredits, - www.UserError{ - ErrorCode: www.ErrorStatusNoProposalCredits, - }}, - - {"invalid proposal", - propInvalid, - usrValid, + { + "proposal is a RFP submission", + av, + *usr, + prop, + www.VoteSummary{}, + fmt.Errorf("proposal is a runoff vote submission"), + }, + { + "not proposal author", + av, + *usr, + prop2, + www.VoteSummary{}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidSignature, - }}, - - {"success", np, usrValid, nil}, + ErrorCode: www.ErrorStatusUserNotAuthor, + }, + }, + { + "valid", + av, + *usr, + propValid, + www.VoteSummary{}, + nil, + }, } - // Run tests - for _, v := range tests { - t.Run(v.name, func(t *testing.T) { - npr, err := p.processNewProposal(*v.np, v.usr) + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateAuthorizeVoteStandard(test.av, test.u, test.pr, test.vs) got := errToStr(err) - want := errToStr(v.want) - + want := errToStr(test.want) if got != want { - t.Errorf("got error %v, want %v", - got, want) - } - - if v.want != nil { - // Test case passes - return - } - - // Validate success case - if npr == nil { - t.Errorf("NewProposalReply is nil") - } - - // Ensure a proposal credit has been deducted - // from the user's account. - u, err := p.db.UserGetById(v.usr.ID) - if err != nil { - t.Error(err) - } - - gotCredits := len(u.UnspentProposalCredits) - wantCredits := len(v.usr.UnspentProposalCredits) - if gotCredits != wantCredits { - t.Errorf("got num proposal credits %v, want %v", - gotCredits, wantCredits) + t.Errorf("got %v, want %v", got, want) } }) } } -func TestProcessEditProposal(t *testing.T) { +func TestValidateAuthorizeVoteRunoff(t *testing.T) { p, cleanup := newTestPoliteiawww(t) defer cleanup() d := newTestPoliteiad(t, p) defer d.Close() - usr, id := newUser(t, p, true, false) - notAuthorUser, _ := newUser(t, p, true, false) - - // Public proposal to be edited - propPublic := newProposalRecord(t, usr, id, www.PropStatusPublic) - tokenPropPublic := propPublic.CensorshipRecord.Token - d.AddRecord(t, convertPropToPD(t, propPublic)) - - root := merkleRoot(t, propPublic.Files, propPublic.Metadata) - s := id.SignMessage([]byte(root)) - sigPropPublic := hex.EncodeToString(s[:]) + usr, id := newUser(t, p, true, true) + usrNotAdmin, _ := newUser(t, p, true, false) - // Edited public proposal - newMD := newFileRandomMD(t) - png := createFilePNG(t, false) - newFiles := []www.File{newMD, *png} + authorize := decredplugin.AuthVoteActionAuthorize - root = merkleRoot(t, newFiles, propPublic.Metadata) - s = id.SignMessage([]byte(root)) - sigPropPublicEdited := hex.EncodeToString(s[:]) + // Public proposal + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) - // Censored proposal to test error case - propCensored := newProposalRecord(t, usr, id, www.PropStatusCensored) - tokenPropCensored := propCensored.CensorshipRecord.Token - d.AddRecord(t, convertPropToPD(t, propCensored)) - - // Authorized vote proposal to test error case - propVoteAuthorized := newProposalRecord(t, usr, id, www.PropStatusPublic) - tokenVoteAuthorized := propVoteAuthorized.CensorshipRecord.Token - d.AddRecord(t, convertPropToPD(t, propVoteAuthorized)) - - cmd := newAuthorizeVoteCmd(t, tokenVoteAuthorized, - propVoteAuthorized.Version, decredplugin.AuthVoteActionAuthorize, id) - d.Plugin(t, cmd) + // Authorize vote + av := newAuthorizeVote(t, token, prop.Version, authorize, id) var tests = []struct { - name string - user *user.User - editProp www.EditProposal - wantError error + name string + av www.AuthorizeVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary + want error }{ { - "invalid proposal token", - usr, - www.EditProposal{ - Token: "invalid-token", - }, + "user is not an admin", + av, + *usrNotAdmin, + prop, + www.VoteSummary{}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusInvalidSigningKey, }, }, { - "wrong proposal status", - usr, - www.EditProposal{ - Token: tokenPropCensored, - }, + "valid", + av, + *usr, + prop, + www.VoteSummary{}, + nil, + }, + } + + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateAuthorizeVoteRunoff(test.av, test.u, test.pr, test.vs) + got := errToStr(err) + want := errToStr(test.want) + if got != want { + t.Errorf("got %v, want %v", got, want) + } + }) + } +} + +func TestValidateVoteOptions(t *testing.T) { + approve := decredplugin.VoteOptionIDApprove + reject := decredplugin.VoteOptionIDReject + invalidVoteOption := []www2.VoteOption{ + newVoteOptionV2(t, "wrong", "", 0x01), + } + missingReject := []www2.VoteOption{ + newVoteOptionV2(t, approve, "", 0x02), + } + missingApprove := []www2.VoteOption{ + newVoteOptionV2(t, reject, "", 0x01), + } + valid := []www2.VoteOption{ + newVoteOptionV2(t, approve, "", 0x02), + newVoteOptionV2(t, reject, "", 0x01), + } + var tests = []struct { + name string + vos []www2.VoteOption + want error + }{ + { + "no vote options found", + []www2.VoteOption{}, www.UserError{ - ErrorCode: www.ErrorStatusWrongStatus, + ErrorCode: www.ErrorStatusInvalidVoteOptions, + ErrorContext: []string{"no vote options found"}, }, }, { - "user is not the author", - notAuthorUser, - www.EditProposal{ - Token: tokenPropPublic, - }, + "invalid vote option", + invalidVoteOption, www.UserError{ - ErrorCode: www.ErrorStatusUserNotAuthor, + ErrorCode: www.ErrorStatusInvalidVoteOptions, + ErrorContext: []string{"invalid vote option id 'wrong'"}, }, }, { - "wrong proposal vote status", - usr, - www.EditProposal{ - Token: tokenVoteAuthorized, - }, + "missing reject vote option", + missingReject, www.UserError{ - ErrorCode: www.ErrorStatusWrongVoteStatus, + ErrorCode: www.ErrorStatusInvalidVoteOptions, + ErrorContext: []string{"missing vote option id 'no'"}, }, }, { - "no changes in proposal md file", - usr, - www.EditProposal{ - Token: tokenPropPublic, - Files: propPublic.Files, - Metadata: propPublic.Metadata, - PublicKey: usr.PublicKey(), - Signature: sigPropPublic, - }, + "missing approve vote option", + missingApprove, www.UserError{ - ErrorCode: www.ErrorStatusNoProposalChanges, + ErrorCode: www.ErrorStatusInvalidVoteOptions, + ErrorContext: []string{"missing vote option id 'yes'"}, }, }, { - "success", - usr, - www.EditProposal{ - Token: tokenPropPublic, - Files: newFiles, - Metadata: propPublic.Metadata, - PublicKey: usr.PublicKey(), - Signature: sigPropPublicEdited, - }, + "valid", + valid, nil, }, } - for _, v := range tests { - t.Run(v.name, func(t *testing.T) { - _, err := p.processEditProposal(v.editProp, v.user) - got := errToStr(err) - want := errToStr(v.wantError) + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateVoteOptions(test.vos) + + if err != nil { + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.want.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.want.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } - // Test if we got expected error - if got != want { - t.Errorf("got error %v, want %v", - got, want) } }) } } -func TestVerifyStatusChange(t *testing.T) { - invalid := www.PropStatusInvalid - notFound := www.PropStatusNotFound - notReviewed := www.PropStatusNotReviewed - censored := www.PropStatusCensored - public := www.PropStatusPublic - unreviewedChanges := www.PropStatusUnreviewedChanges - abandoned := www.PropStatusAbandoned +func TestValidateVoteBit(t *testing.T) { + approve := decredplugin.VoteOptionIDApprove + reject := decredplugin.VoteOptionIDReject + vote := www2.Vote{ + Mask: 0x03, + Options: []www2.VoteOption{ + newVoteOptionV2(t, approve, "", 0x02), + newVoteOptionV2(t, reject, "", 0x01), + }, + } - // Setup tests var tests = []struct { - name string - current www.PropStatusT - next www.PropStatusT - wantError bool + name string + vote www2.Vote + bit uint64 + want error }{ - {"not reviewed to invalid", notReviewed, invalid, true}, - {"not reviewed to not found", notReviewed, notFound, true}, - {"not reviewed to censored", notReviewed, censored, false}, - {"not reviewed to public", notReviewed, public, false}, - {"not reviewed to unreviewed changes", notReviewed, unreviewedChanges, - true}, - {"not reviewed to abandoned", notReviewed, abandoned, true}, - {"censored to invalid", censored, invalid, true}, - {"censored to not found", censored, notFound, true}, - {"censored to not reviewed", censored, notReviewed, true}, - {"censored to public", censored, public, true}, - {"censored to unreviewed changes", censored, unreviewedChanges, true}, - {"censored to abandoned", censored, abandoned, true}, - {"public to invalid", public, invalid, true}, - {"public to not found", public, notFound, true}, - {"public to not reviewed", public, notReviewed, true}, - {"public to censored", public, censored, true}, - {"public to unreviewed changes", public, unreviewedChanges, true}, - {"public to abandoned", public, abandoned, false}, - {"unreviewed changes to invalid", unreviewedChanges, invalid, true}, - {"unreviewed changes to not found", unreviewedChanges, notFound, true}, - {"unreviewed changes to not reviewed", unreviewedChanges, notReviewed, - true}, - {"unreviewed changes to censored", unreviewedChanges, censored, false}, - {"unreviewed changes to public", unreviewedChanges, public, false}, - {"unreviewed changes to abandoned", unreviewedChanges, abandoned, true}, - {"abandoned to invalid", abandoned, invalid, true}, - {"abandoned to not found", abandoned, notFound, true}, - {"abandoned to not reviewed", abandoned, notReviewed, true}, - {"abandoned to censored", abandoned, censored, true}, - {"abandoned to public", abandoned, public, true}, - {"abandoned to unreviewed changes", abandoned, unreviewedChanges, true}, + { + "vote corrupt", + www2.Vote{}, + 0x01, + fmt.Errorf("vote corrupt"), + }, + { + "invalid bit", + vote, + 0, + fmt.Errorf("invalid bit 0x0"), + }, + { + "invalid bit mask", + vote, + 0x04, + fmt.Errorf("invalid mask 0x3 bit 0x4"), + }, + { + "bit not found", + vote, + 0x03, + fmt.Errorf("bit not found 0x3"), + }, + { + "valid", + vote, + 0x02, + nil, + }, } - // Run tests - for _, v := range tests { - t.Run(v.name, func(t *testing.T) { - err := verifyStatusChange(v.current, v.next) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateVoteBit(test.vote, test.bit) got := errToStr(err) - if v.wantError { - want := www.ErrorStatus[www.ErrorStatusInvalidPropStatusTransition] - if got != want { - t.Errorf("got error %v, want %v", - got, want) - } - - // Test case passes - return - } - - if err != nil { - t.Errorf("got error %v, want nil", - got) + want := errToStr(test.want) + if got != want { + t.Errorf("got %v, want %v", got, want) } }) } } -func TestProcessSetProposalStatus(t *testing.T) { - // Setup test environment +func TestValidateStartVote(t *testing.T) { p, cleanup := newTestPoliteiawww(t) defer cleanup() d := newTestPoliteiad(t, p) defer d.Close() - // Create test data - admin, id := newUser(t, p, true, true) - - changeMsgCensored := "proposal did not meet guidelines" - changeMsgAbandoned := "no activity" - - statusPublic := strconv.Itoa(int(www.PropStatusPublic)) - statusCensored := strconv.Itoa(int(www.PropStatusCensored)) - statusAbandoned := strconv.Itoa(int(www.PropStatusAbandoned)) - - propNotReviewed := newProposalRecord(t, admin, id, www.PropStatusNotReviewed) - propPublic := newProposalRecord(t, admin, id, www.PropStatusPublic) + usr, id := newUser(t, p, true, false) - tokenNotReviewed := propNotReviewed.CensorshipRecord.Token - tokenPublic := propPublic.CensorshipRecord.Token - tokenNotFound := "abc" + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) - msg := fmt.Sprintf("%s%s", tokenNotReviewed, statusPublic) - s := id.SignMessage([]byte(msg)) - sigNotReviewedToPublic := hex.EncodeToString(s[:]) + propUnvetted := newProposalRecord(t, usr, id, www.PropStatusNotReviewed) + d.AddRecord(t, convertPropToPD(t, propUnvetted)) - msg = fmt.Sprintf("%s%s%s", tokenNotReviewed, - statusCensored, changeMsgCensored) - s = id.SignMessage([]byte(msg)) - sigNotReviewedToCensored := hex.EncodeToString(s[:]) + standard := www2.VoteTypeStandard - msg = fmt.Sprintf("%s%s%s", tokenPublic, - statusAbandoned, changeMsgAbandoned) - s = id.SignMessage([]byte(msg)) - sigPublicToAbandoned := hex.EncodeToString(s[:]) + sv := newStartVote(t, token, 1, minDuration, standard, id) - msg = fmt.Sprintf("%s%s", tokenNotFound, statusPublic) - s = id.SignMessage([]byte(msg)) - sigNotFound := hex.EncodeToString(s[:]) + svInvalidToken := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidToken.Vote.Token = "" - msg = fmt.Sprintf("%s%s%s", tokenNotReviewed, - statusAbandoned, changeMsgAbandoned) - s = id.SignMessage([]byte(msg)) - sigNotReviewedToAbandoned := hex.EncodeToString(s[:]) + svInvalidBit := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidBit.Vote.Options[0].Bits = 0 - // Add success case proposals to politeiad - d.AddRecord(t, convertPropToPD(t, propNotReviewed)) - d.AddRecord(t, convertPropToPD(t, propPublic)) + svInvalidOpt := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidOpt.Vote.Options[0].Id = "wrong" - // Create a proposal whose vote has been authorized - propVoteAuthorized := newProposalRecord(t, admin, id, www.PropStatusPublic) - tokenVoteAuthorized := propVoteAuthorized.CensorshipRecord.Token + svInvalidMinDuration := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidMinDuration.Vote.Duration = 1 - msg = fmt.Sprintf("%s%s%s", tokenVoteAuthorized, - statusAbandoned, changeMsgAbandoned) - s = id.SignMessage([]byte(msg)) - sigVoteAuthorizedToAbandoned := hex.EncodeToString(s[:]) + svInvalidMaxDuration := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidMaxDuration.Vote.Duration = 4050 - d.AddRecord(t, convertPropToPD(t, propVoteAuthorized)) - cmd := newAuthorizeVoteCmd(t, tokenVoteAuthorized, - propVoteAuthorized.Version, decredplugin.AuthVoteActionAuthorize, id) - d.Plugin(t, cmd) + svInvalidQuorum := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidQuorum.Vote.QuorumPercentage = 110 - // Create a proposal whose voting period has started - propVoteStarted := newProposalRecord(t, admin, id, www.PropStatusPublic) - tokenVoteStarted := propVoteStarted.CensorshipRecord.Token + svInvalidPass := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidPass.Vote.PassPercentage = 110 - msg = fmt.Sprintf("%s%s%s", tokenVoteStarted, - statusAbandoned, changeMsgAbandoned) - s = id.SignMessage([]byte(msg)) - sigVoteStartedToAbandoned := hex.EncodeToString(s[:]) + svInvalidKey := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidKey.PublicKey = "" - d.AddRecord(t, convertPropToPD(t, propVoteStarted)) - cmd = newStartVoteCmd(t, tokenVoteStarted, 1, id) - d.Plugin(t, cmd) + svInvalidSig := newStartVote(t, token, 1, minDuration, standard, id) + svInvalidSig.Signature = "" - // Ensure that admins are not allowed to change the status of - // their own proposal on mainnet. This is run individually - // because it requires flipping the testnet config setting. - t.Run("admin is author", func(t *testing.T) { - p.cfg.TestNet = false - defer func() { - p.cfg.TestNet = true - }() + svInvalidVersion := newStartVote(t, token, 2, minDuration, standard, id) - sps := www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusPublic, - Signature: sigNotReviewedToPublic, - PublicKey: admin.PublicKey(), - } - - _, err := p.processSetProposalStatus(sps, admin) - got := errToStr(err) - want := www.ErrorStatus[www.ErrorStatusReviewerAdminEqualsAuthor] - if got != want { - t.Errorf("got error %v, want %v", - got, want) - } - }) - - // Setup tests var tests = []struct { - name string - usr *user.User - sps www.SetProposalStatus - want error + name string + sv www2.StartVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary + wantUE bool + want error }{ - // This is an admin route so it can be assumed that the - // user has been validated and is an admin. - - {"no change message for censored", admin, - www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusCensored, - Signature: sigNotReviewedToCensored, - PublicKey: admin.PublicKey(), + { + "invalid proposal token", + svInvalidToken, + *usr, + prop, + www.VoteSummary{}, + false, + fmt.Errorf("invalid token %v", svInvalidToken.Vote.Token), + }, + { + "invalid vote bit", + svInvalidBit, + *usr, + prop, + www.VoteSummary{}, + true, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidPropVoteBits, }, + }, + { + "invalid vote option", + svInvalidOpt, + *usr, + prop, + www.VoteSummary{}, + true, www.UserError{ - ErrorCode: www.ErrorStatusChangeMessageCannotBeBlank, - }}, - - {"no change message for abandoned", admin, - www.SetProposalStatus{ - Token: tokenPublic, - ProposalStatus: www.PropStatusAbandoned, - Signature: sigPublicToAbandoned, - PublicKey: admin.PublicKey(), + ErrorCode: www.ErrorStatusInvalidVoteOptions, }, + }, + { + "invalid minimum duration", + svInvalidMinDuration, + *usr, + prop, + www.VoteSummary{}, + true, www.UserError{ - ErrorCode: www.ErrorStatusChangeMessageCannotBeBlank, - }}, - - {"invalid public key", admin, - www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusPublic, - Signature: sigNotReviewedToPublic, - PublicKey: "", + ErrorCode: www.ErrorStatusInvalidPropVoteParams, + ErrorContext: []string{ + fmt.Sprintf("vote duration must be >= %v", minDuration), + }, + }, + }, + { + "invalid maximum duration", + svInvalidMaxDuration, + *usr, + prop, + www.VoteSummary{}, + true, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidPropVoteParams, + ErrorContext: []string{ + fmt.Sprintf("vote duration must be <= %v", maxDuration), + }, + }, + }, + { + "quorum too large", + svInvalidQuorum, + *usr, + prop, + www.VoteSummary{}, + true, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidPropVoteParams, + ErrorContext: []string{"quorum percentage cannot be >100"}, + }, + }, + { + "pass percentage too large", + svInvalidPass, + *usr, + prop, + www.VoteSummary{}, + true, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidPropVoteParams, + ErrorContext: []string{"pass percentage cannot be >100"}, }, + }, + { + "invalid public key from start vote", + svInvalidKey, + *usr, + prop, + www.VoteSummary{}, + true, www.UserError{ ErrorCode: www.ErrorStatusInvalidSigningKey, - }}, - - {"invalid signature", admin, - www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusPublic, - Signature: "", - PublicKey: admin.PublicKey(), }, + }, + { + "invalid signature", + svInvalidSig, + *usr, + prop, + www.VoteSummary{}, + true, www.UserError{ ErrorCode: www.ErrorStatusInvalidSignature, - }}, - - {"invalid proposal token", admin, - www.SetProposalStatus{ - Token: tokenNotFound, - ProposalStatus: www.PropStatusPublic, - Signature: sigNotFound, - PublicKey: admin.PublicKey(), }, + }, + { + "invalid proposal version", + svInvalidVersion, + *usr, + prop, + www.VoteSummary{}, + true, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, - }}, - - {"invalid status change", admin, - www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusAbandoned, - StatusChangeMessage: changeMsgAbandoned, - Signature: sigNotReviewedToAbandoned, - PublicKey: admin.PublicKey(), + ErrorCode: www.ErrorStatusInvalidProposalVersion, + ErrorContext: []string{"got 2, want 1"}, }, + }, + { + "invalid proposal status", + sv, + *usr, + propUnvetted, + www.VoteSummary{}, + true, www.UserError{ - ErrorCode: www.ErrorStatusInvalidPropStatusTransition, - }}, - - {"unvetted success", admin, - www.SetProposalStatus{ - Token: tokenNotReviewed, - ProposalStatus: www.PropStatusPublic, - Signature: sigNotReviewedToPublic, - PublicKey: admin.PublicKey(), - }, nil}, - - {"vote already authorized", admin, - www.SetProposalStatus{ - Token: tokenVoteAuthorized, - ProposalStatus: www.PropStatusAbandoned, - StatusChangeMessage: changeMsgAbandoned, - Signature: sigVoteAuthorizedToAbandoned, - PublicKey: admin.PublicKey(), + ErrorCode: www.ErrorStatusWrongStatus, + ErrorContext: []string{"proposal is not public"}, }, - www.UserError{ - ErrorCode: www.ErrorStatusWrongVoteStatus, - }}, - - {"vote already started", admin, - www.SetProposalStatus{ - Token: tokenVoteStarted, - ProposalStatus: www.PropStatusAbandoned, - StatusChangeMessage: changeMsgAbandoned, - Signature: sigVoteStartedToAbandoned, - PublicKey: admin.PublicKey(), + }, + { + "vote has already started", + sv, + *usr, + prop, + www.VoteSummary{ + EndHeight: 1024, }, + true, www.UserError{ - ErrorCode: www.ErrorStatusWrongVoteStatus, - }}, - - {"vetted success", admin, - www.SetProposalStatus{ - Token: tokenPublic, - ProposalStatus: www.PropStatusAbandoned, - StatusChangeMessage: changeMsgAbandoned, - Signature: sigPublicToAbandoned, - PublicKey: admin.PublicKey(), - }, nil}, + ErrorCode: www.ErrorStatusWrongVoteStatus, + ErrorContext: []string{"vote already started"}, + }, + }, + { + "valid", + sv, + *usr, + prop, + www.VoteSummary{}, + false, + nil, + }, } - // Run tests - for _, v := range tests { - t.Run(v.name, func(t *testing.T) { - reply, err := p.processSetProposalStatus(v.sps, v.usr) - got := errToStr(err) - want := errToStr(v.want) - if got != want { - t.Errorf("got error %v, want %v", - got, want) - } - - if err != nil { - // Test case passes - return - } - - // Validate updated proposal - if reply.Proposal.Status != v.sps.ProposalStatus { - t.Errorf("got proposal status %v, want %v", - reply.Proposal.Status, v.sps.ProposalStatus) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateStartVote(test.sv, test.u, test.pr, test.vs, minDuration, maxDuration) + + // Check if wanted error is a UserError struct + switch test.wantUE { + case true: + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.want.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.want.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } + case false: + got := errToStr(err) + want := errToStr(test.want) + if got != want { + t.Errorf("got %v, want %v", got, want) + } } }) } } -func TestProcessAllVetted(t *testing.T) { - // Setup test environment +func TestValidateStartVoteStandard(t *testing.T) { p, cleanup := newTestPoliteiawww(t) defer cleanup() d := newTestPoliteiad(t, p) defer d.Close() - // Create test data - tokenValid := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5d" - tokenNotHex := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351zzz" - tokenShort := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5" - tokenLong := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5dd" + usr, id := newUser(t, p, true, false) + + // RFP proposal + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + linkBy := time.Now().Add(time.Hour * 24 * 30).Unix() + makeProposalRFP(t, &prop, []string{}, linkBy) + d.AddRecord(t, convertPropToPD(t, prop)) + + // RFP submission + rfpSubmission := newProposalRecord(t, usr, id, www.PropStatusPublic) + makeProposalRFPSubmissions(t, []*www.ProposalRecord{&rfpSubmission}, token) + d.AddRecord(t, convertPropToPD(t, rfpSubmission)) + + sv := newStartVote(t, token, 1, minDuration, www2.VoteTypeStandard, id) + + // Invalid vote type + svInvalidType := newStartVote(t, token, 1, minDuration, + www2.VoteTypeRunoff, id) + svInvalidType.Vote.Type = www2.VoteTypeRunoff + + // RFP proposal linkBy less than min + propMinLb := newProposalRecord(t, usr, id, www.PropStatusPublic) + makeProposalRFP(t, &propMinLb, []string{}, p.linkByPeriodMin()) + d.AddRecord(t, convertPropToPD(t, propMinLb)) + + // RFP proposal linkBy more than max + propMaxLb := newProposalRecord(t, usr, id, www.PropStatusPublic) + maxLinkBy := time.Now().Unix() + (p.linkByPeriodMax() * 2) + makeProposalRFP(t, &propMaxLb, []string{}, maxLinkBy) + d.AddRecord(t, convertPropToPD(t, propMaxLb)) + + // Three days vote duration + svVoteDuration := newStartVote(t, token, 1, 864, www2.VoteTypeStandard, id) - // Setup tests var tests = []struct { name string - av www.GetAllVetted + sv www2.StartVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary + mm []uint32 // min duration and max duration want error }{ - {"before token not hex", - www.GetAllVetted{ - Before: tokenNotHex, - }, + { + "invalid vote type", + svInvalidType, + *usr, + prop, + www.VoteSummary{}, + []uint32{minDuration, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusInvalidVoteType, + ErrorContext: []string{ + fmt.Sprintf("vote type must be %v", www2.VoteTypeStandard), + }, }, }, - {"before token invalid length short", - www.GetAllVetted{ - Before: tokenShort, + { + "invalid vote status", + sv, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusNotAuthorized, }, + []uint32{minDuration, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusWrongVoteStatus, + ErrorContext: []string{"vote not authorized"}, }, }, - {"before token invalid length long", - www.GetAllVetted{ - Before: tokenLong, + { + "proposal cannot be rfp submission", + sv, + *usr, + rfpSubmission, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + []uint32{minDuration, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusWrongProposalType, + ErrorContext: []string{"cannot be an rfp submission"}, }, }, - {"after token not hex", - www.GetAllVetted{ - After: tokenNotHex, + { + "less than min linkby period", + sv, + *usr, + propMinLb, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + []uint32{minDuration, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusInvalidLinkBy, + ErrorContext: []string{ + fmt.Sprintf("linkby period must be at least %v seconds"+ + " from the start of the proposal vote", p.linkByPeriodMin()), + }, }, }, - {"after token invalid length short", - www.GetAllVetted{ - After: tokenShort, + { + "more than max linkby period", + sv, + *usr, + propMaxLb, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + []uint32{minDuration, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusInvalidLinkBy, + ErrorContext: []string{ + fmt.Sprintf("linkby period cannot be more than %v seconds"+ + " from the start of the proposal vote", p.linkByPeriodMax()), + }, }, }, - {"after token invalid length long", - www.GetAllVetted{ - After: tokenLong, + { + "three days vote duration", + svVoteDuration, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + []uint32{100, maxDuration}, www.UserError{ - ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorCode: www.ErrorStatusInvalidLinkBy, }, }, - {"valid before token", - www.GetAllVetted{ - Before: tokenValid, - }, - nil, - }, - {"valid after token", - www.GetAllVetted{ - After: tokenValid, + { + "valid", + sv, + *usr, + prop, + www.VoteSummary{ + Status: www.PropVoteStatusAuthorized, }, + []uint32{minDuration, maxDuration}, nil, }, + } - // XXX only partial test coverage has been added to this route + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateStartVoteStandard( + test.sv, + test.u, + test.pr, + test.vs, + test.mm[0], + test.mm[1], + p.linkByPeriodMin(), + p.linkByPeriodMax(), + ) + + if err != nil { + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.want.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.want.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } + } + }) } +} - // Run tests - for _, v := range tests { - t.Run(v.name, func(t *testing.T) { - _, err := p.processAllVetted(v.av) - got := errToStr(err) - want := errToStr(v.want) - if got != want { - t.Errorf("got error %v, want %v", - got, want) +func TestValidateStartVoteRunoff(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + usr, id := newUser(t, p, true, false) + + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) + + rfpSubmission := newProposalRecord(t, usr, id, www.PropStatusPublic) + makeProposalRFPSubmissions(t, []*www.ProposalRecord{&rfpSubmission}, token) + d.AddRecord(t, convertPropToPD(t, rfpSubmission)) + + sv := newStartVote(t, token, 1, minDuration, www2.VoteTypeRunoff, id) + + svInvalidType := newStartVote(t, token, 1, minDuration, + www2.VoteTypeStandard, id) + + var tests = []struct { + name string + sv www2.StartVote + u user.User + pr www.ProposalRecord + vs www.VoteSummary + want error + }{ + { + "invalid vote type", + svInvalidType, + *usr, + prop, + www.VoteSummary{}, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidVoteType, + ErrorContext: []string{ + fmt.Sprintf("%v vote type must be %v", + svInvalidType.Vote.Token, www2.VoteTypeRunoff), + }, + }, + }, + { + "proposal is not a rfp submission", + sv, + *usr, + prop, + www.VoteSummary{}, + www.UserError{ + ErrorCode: www.ErrorStatusWrongProposalType, + ErrorContext: []string{ + fmt.Sprintf("%v is not an rfp submission", sv.Vote.Token)}, + }, + }, + { + "valid", + sv, + *usr, + rfpSubmission, + www.VoteSummary{ + Status: www.PropVoteStatusNotAuthorized, + }, + nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateStartVoteRunoff( + test.sv, + test.u, + test.pr, + test.vs, + minDuration, + maxDuration, + ) + + if err != nil { + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.want.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.want.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } + } + }) + } +} + +func TestFilterProposals(t *testing.T) { + // Test proposal page size. Only a single page of proposals + // should be returned. + t.Run("proposal page size", func(t *testing.T) { + pq := proposalsFilter{ + StateMap: map[www.PropStateT]bool{ + www.PropStateVetted: true, + }, + } + + // We use simplified userIDs, timestamps, and censorship + // record tokens. This is ok since filterProps() does not + // check the validity of the data. + c := www.ProposalListPageSize + 5 + propsPageTest := make([]www.ProposalRecord, 0, c) + for i := 1; i <= c; i++ { + propsPageTest = append(propsPageTest, www.ProposalRecord{ + State: www.PropStateVetted, + UserId: strconv.Itoa(i), + Timestamp: int64(i), + CensorshipRecord: www.CensorshipRecord{ + Token: strconv.Itoa(i), + }, + }) + } + + out := filterProps(pq, propsPageTest) + if len(out) != www.ProposalListPageSize { + t.Errorf("got %v, want %v", len(out), www.ProposalListPageSize) + } + }) + + // Create data for test table. We use simplified userIDs, + // timestamps, and censorship record tokens. This is ok since + // filterProps() does not check the validity of the data. + props := make(map[int]*www.ProposalRecord, 5) + for i := 1; i <= 5; i++ { + props[i] = &www.ProposalRecord{ + State: www.PropStateVetted, + UserId: strconv.Itoa(i), + Timestamp: int64(i), + CensorshipRecord: www.CensorshipRecord{ + Token: strconv.Itoa(i), + }, + } + } + + // Change the State of a few proposals so that they are not + // all the same. + props[2].State = www.PropStateUnvetted + props[4].State = www.PropStateUnvetted + + // Setup tests + var tests = []struct { + name string + req proposalsFilter + input []www.ProposalRecord + want []www.ProposalRecord + }{ + { + "filter by State", + proposalsFilter{ + StateMap: map[www.PropStateT]bool{ + www.PropStateUnvetted: true, + }, + }, + []www.ProposalRecord{ + *props[1], *props[2], *props[3], *props[4], *props[5], + }, + []www.ProposalRecord{ + *props[4], *props[2], + }, + }, + + { + "filter by UserID", + proposalsFilter{ + UserID: "1", + StateMap: map[www.PropStateT]bool{ + www.PropStateVetted: true, + }, + }, + []www.ProposalRecord{ + *props[1], *props[2], *props[3], *props[4], *props[5], + }, + []www.ProposalRecord{ + *props[1], + }, + }, + { + "filter by Before", + proposalsFilter{ + Before: props[3].CensorshipRecord.Token, + StateMap: map[www.PropStateT]bool{ + www.PropStateUnvetted: true, + www.PropStateVetted: true, + }, + }, + []www.ProposalRecord{ + *props[1], *props[2], *props[3], *props[4], *props[5], + }, + []www.ProposalRecord{ + *props[5], *props[4], + }, + }, + + { + "filter by After", + proposalsFilter{ + After: props[3].CensorshipRecord.Token, + StateMap: map[www.PropStateT]bool{ + www.PropStateUnvetted: true, + www.PropStateVetted: true, + }, + }, + []www.ProposalRecord{ + *props[1], *props[2], *props[3], *props[4], *props[5], + }, + []www.ProposalRecord{ + *props[2], *props[1], + }, + }, + + { + "unsorted proposals", + proposalsFilter{ + StateMap: map[www.PropStateT]bool{ + www.PropStateUnvetted: true, + www.PropStateVetted: true, + }, + }, + []www.ProposalRecord{ + *props[3], *props[4], *props[1], *props[5], *props[2], + }, + []www.ProposalRecord{ + *props[5], *props[4], *props[3], *props[2], *props[1], + }, + }, + } + + // Run tests + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := filterProps(test.req, test.input) + + // Pull out the tokens to make it easier to view the + // difference between want and got + want := make([]string, len(test.want)) + for _, p := range test.want { + want = append(want, p.CensorshipRecord.Token) + } + got := make([]string, len(out)) + for _, p := range out { + got = append(got, p.CensorshipRecord.Token) + } + + // Check if want and got are the same + if len(want) != len(got) { + goto fail + } + for i, w := range want { + if w != got[i] { + goto fail + } + } + + // success; want and got are the same + return + + fail: + t.Errorf("got %v, want %v", got, want) + }) + } +} + +func TestProcessNewProposal(t *testing.T) { + // Setup test environment + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + td := testpoliteiad.New(t, p.cache) + defer td.Close() + + p.cfg.RPCHost = td.URL + p.cfg.Identity = td.PublicIdentity + + // Create a user that has not paid their registration fee. + usrUnpaid, _ := newUser(t, p, true, false) + + // Create a user that has paid their registration + // fee but does not have any proposal credits. + usrNoCredits, _ := newUser(t, p, true, false) + payRegistrationFee(t, p, usrNoCredits) + + // Create a user that has paid their registration + // fee and has purchased proposal credits. + usrValid, id := newUser(t, p, true, false) + payRegistrationFee(t, p, usrValid) + addProposalCredits(t, p, usrValid, 10) + + // Create a NewProposal + f := newFileRandomMD(t) + np := createNewProposal(t, id, []www.File{f}, "") + + // Invalid proposal + propInvalid := createNewProposal(t, id, []www.File{f}, "") + propInvalid.Signature = "" + + // Setup tests + var tests = []struct { + name string + np *www.NewProposal + usr *user.User + want error + }{ + { + "unpaid registration fee", + np, + usrUnpaid, + www.UserError{ + ErrorCode: www.ErrorStatusUserNotPaid, + }}, + + { + "no proposal credits", + np, + usrNoCredits, + www.UserError{ + ErrorCode: www.ErrorStatusNoProposalCredits, + }}, + + { + "invalid proposal", + propInvalid, + usrValid, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSignature, + }}, + + { + "success", + np, + usrValid, + nil, + }, + } + + // Run tests + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + npr, err := p.processNewProposal(*v.np, v.usr) + got := errToStr(err) + want := errToStr(v.want) + + if got != want { + t.Errorf("got error %v, want %v", + got, want) + } + + if v.want != nil { + // Test case passes + return + } + + // Validate success case + if npr == nil { + t.Errorf("NewProposalReply is nil") + } + + // Ensure a proposal credit has been deducted + // from the user's account. + u, err := p.db.UserGetById(v.usr.ID) + if err != nil { + t.Error(err) + } + + gotCredits := len(u.UnspentProposalCredits) + wantCredits := len(v.usr.UnspentProposalCredits) + if gotCredits != wantCredits { + t.Errorf("got num proposal credits %v, want %v", + gotCredits, wantCredits) + } + }) + } +} + +func TestProcessEditProposal(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + usr, id := newUser(t, p, true, false) + notAuthorUser, _ := newUser(t, p, true, false) + + // Public proposal to be edited + propPublic := newProposalRecord(t, usr, id, www.PropStatusPublic) + tokenPropPublic := propPublic.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, propPublic)) + + root := merkleRoot(t, propPublic.Files, propPublic.Metadata) + s := id.SignMessage([]byte(root)) + sigPropPublic := hex.EncodeToString(s[:]) + + // Edited public proposal + newMD := newFileRandomMD(t) + png := createFilePNG(t, false) + newFiles := []www.File{newMD, *png} + + root = merkleRoot(t, newFiles, propPublic.Metadata) + s = id.SignMessage([]byte(root)) + sigPropPublicEdited := hex.EncodeToString(s[:]) + + // Censored proposal to test error case + propCensored := newProposalRecord(t, usr, id, www.PropStatusCensored) + tokenPropCensored := propCensored.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, propCensored)) + + // Authorized vote proposal to test error case + propVoteAuthorized := newProposalRecord(t, usr, id, www.PropStatusPublic) + tokenVoteAuthorized := propVoteAuthorized.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, propVoteAuthorized)) + + cmd := newAuthorizeVoteCmd(t, tokenVoteAuthorized, + propVoteAuthorized.Version, decredplugin.AuthVoteActionAuthorize, id) + d.Plugin(t, cmd) + + var tests = []struct { + name string + user *user.User + editProp www.EditProposal + wantError error + }{ + { + "invalid proposal token", + usr, + www.EditProposal{ + Token: "invalid-token", + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "wrong proposal status", + usr, + www.EditProposal{ + Token: tokenPropCensored, + }, + www.UserError{ + ErrorCode: www.ErrorStatusWrongStatus, + }, + }, + { + "user is not the author", + notAuthorUser, + www.EditProposal{ + Token: tokenPropPublic, + }, + www.UserError{ + ErrorCode: www.ErrorStatusUserNotAuthor, + }, + }, + { + "wrong proposal vote status", + usr, + www.EditProposal{ + Token: tokenVoteAuthorized, + }, + www.UserError{ + ErrorCode: www.ErrorStatusWrongVoteStatus, + }, + }, + { + "no changes in proposal md file", + usr, + www.EditProposal{ + Token: tokenPropPublic, + Files: propPublic.Files, + Metadata: propPublic.Metadata, + PublicKey: usr.PublicKey(), + Signature: sigPropPublic, + }, + www.UserError{ + ErrorCode: www.ErrorStatusNoProposalChanges, + }, + }, + { + "success", + usr, + www.EditProposal{ + Token: tokenPropPublic, + Files: newFiles, + Metadata: propPublic.Metadata, + PublicKey: usr.PublicKey(), + Signature: sigPropPublicEdited, + }, + nil, + }, + } + + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + _, err := p.processEditProposal(v.editProp, v.user) + got := errToStr(err) + want := errToStr(v.wantError) + + // Test if we got expected error + if got != want { + t.Errorf("got error %v, want %v", got, want) + } + }) + } +} + +func TestProcessSetProposalStatus(t *testing.T) { + // Setup test environment + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + // Create test data + admin, id := newUser(t, p, true, true) + + changeMsgCensored := "proposal did not meet guidelines" + changeMsgAbandoned := "no activity" + + statusPublic := strconv.Itoa(int(www.PropStatusPublic)) + statusCensored := strconv.Itoa(int(www.PropStatusCensored)) + statusAbandoned := strconv.Itoa(int(www.PropStatusAbandoned)) + + propNotReviewed := newProposalRecord(t, admin, id, www.PropStatusNotReviewed) + propPublic := newProposalRecord(t, admin, id, www.PropStatusPublic) + + tokenNotReviewed := propNotReviewed.CensorshipRecord.Token + tokenPublic := propPublic.CensorshipRecord.Token + tokenNotFound := "abc" + + msg := fmt.Sprintf("%s%s", tokenNotReviewed, statusPublic) + s := id.SignMessage([]byte(msg)) + sigNotReviewedToPublic := hex.EncodeToString(s[:]) + + msg = fmt.Sprintf("%s%s%s", tokenNotReviewed, + statusCensored, changeMsgCensored) + s = id.SignMessage([]byte(msg)) + sigNotReviewedToCensored := hex.EncodeToString(s[:]) + + msg = fmt.Sprintf("%s%s%s", tokenPublic, + statusAbandoned, changeMsgAbandoned) + s = id.SignMessage([]byte(msg)) + sigPublicToAbandoned := hex.EncodeToString(s[:]) + + msg = fmt.Sprintf("%s%s", tokenNotFound, statusPublic) + s = id.SignMessage([]byte(msg)) + sigNotFound := hex.EncodeToString(s[:]) + + msg = fmt.Sprintf("%s%s%s", tokenNotReviewed, + statusAbandoned, changeMsgAbandoned) + s = id.SignMessage([]byte(msg)) + sigNotReviewedToAbandoned := hex.EncodeToString(s[:]) + + // Add success case proposals to politeiad + d.AddRecord(t, convertPropToPD(t, propNotReviewed)) + d.AddRecord(t, convertPropToPD(t, propPublic)) + + // Create a proposal whose vote has been authorized + propVoteAuthorized := newProposalRecord(t, admin, id, www.PropStatusPublic) + tokenVoteAuthorized := propVoteAuthorized.CensorshipRecord.Token + + msg = fmt.Sprintf("%s%s%s", tokenVoteAuthorized, + statusAbandoned, changeMsgAbandoned) + s = id.SignMessage([]byte(msg)) + sigVoteAuthorizedToAbandoned := hex.EncodeToString(s[:]) + + d.AddRecord(t, convertPropToPD(t, propVoteAuthorized)) + cmd := newAuthorizeVoteCmd(t, tokenVoteAuthorized, + propVoteAuthorized.Version, decredplugin.AuthVoteActionAuthorize, id) + d.Plugin(t, cmd) + + // Create a proposal whose voting period has started + propVoteStarted := newProposalRecord(t, admin, id, www.PropStatusPublic) + tokenVoteStarted := propVoteStarted.CensorshipRecord.Token + + msg = fmt.Sprintf("%s%s%s", tokenVoteStarted, + statusAbandoned, changeMsgAbandoned) + s = id.SignMessage([]byte(msg)) + sigVoteStartedToAbandoned := hex.EncodeToString(s[:]) + + d.AddRecord(t, convertPropToPD(t, propVoteStarted)) + cmd = newStartVoteCmd(t, tokenVoteStarted, 1, 2016, id) + d.Plugin(t, cmd) + + // Ensure that admins are not allowed to change the status of + // their own proposal on mainnet. This is run individually + // because it requires flipping the testnet config setting. + t.Run("admin is author", func(t *testing.T) { + p.cfg.TestNet = false + defer func() { + p.cfg.TestNet = true + }() + + sps := www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusPublic, + Signature: sigNotReviewedToPublic, + PublicKey: admin.PublicKey(), + } + + _, err := p.processSetProposalStatus(sps, admin) + got := errToStr(err) + want := www.ErrorStatus[www.ErrorStatusReviewerAdminEqualsAuthor] + if got != want { + t.Errorf("got error %v, want %v", + got, want) + } + }) + + // Setup tests + var tests = []struct { + name string + usr *user.User + sps www.SetProposalStatus + want error + }{ + // This is an admin route so it can be assumed that the + // user has been validated and is an admin. + + { + "no change message for censored", + admin, + www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusCensored, + Signature: sigNotReviewedToCensored, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusChangeMessageCannotBeBlank, + }}, + + { + "no change message for abandoned", + admin, + www.SetProposalStatus{ + Token: tokenPublic, + ProposalStatus: www.PropStatusAbandoned, + Signature: sigPublicToAbandoned, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusChangeMessageCannotBeBlank, + }}, + + { + "invalid public key", + admin, + www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusPublic, + Signature: sigNotReviewedToPublic, + PublicKey: "", + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSigningKey, + }}, + + { + "invalid signature", + admin, + www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusPublic, + Signature: "", + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidSignature, + }}, + + { + "invalid proposal token", + admin, + www.SetProposalStatus{ + Token: tokenNotFound, + ProposalStatus: www.PropStatusPublic, + Signature: sigNotFound, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }}, + + { + "invalid status change", + admin, + www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusAbandoned, + StatusChangeMessage: changeMsgAbandoned, + Signature: sigNotReviewedToAbandoned, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidPropStatusTransition, + }}, + { + "unvetted success", + admin, + www.SetProposalStatus{ + Token: tokenNotReviewed, + ProposalStatus: www.PropStatusPublic, + Signature: sigNotReviewedToPublic, + PublicKey: admin.PublicKey(), + }, nil}, + + { + "vote already authorized", + admin, + www.SetProposalStatus{ + Token: tokenVoteAuthorized, + ProposalStatus: www.PropStatusAbandoned, + StatusChangeMessage: changeMsgAbandoned, + Signature: sigVoteAuthorizedToAbandoned, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusWrongVoteStatus, + }}, + + { + "vote already started", + admin, + www.SetProposalStatus{ + Token: tokenVoteStarted, + ProposalStatus: www.PropStatusAbandoned, + StatusChangeMessage: changeMsgAbandoned, + Signature: sigVoteStartedToAbandoned, + PublicKey: admin.PublicKey(), + }, + www.UserError{ + ErrorCode: www.ErrorStatusWrongVoteStatus, + }}, + + { + "vetted success", + admin, + www.SetProposalStatus{ + Token: tokenPublic, + ProposalStatus: www.PropStatusAbandoned, + StatusChangeMessage: changeMsgAbandoned, + Signature: sigPublicToAbandoned, + PublicKey: admin.PublicKey(), + }, nil}, + } + + // Run tests + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + reply, err := p.processSetProposalStatus(v.sps, v.usr) + got := errToStr(err) + want := errToStr(v.want) + if got != want { + t.Errorf("got error %v, want %v", + got, want) + } + + if err != nil { + // Test case passes + return + } + + // Validate updated proposal + if reply.Proposal.Status != v.sps.ProposalStatus { + t.Errorf("got proposal status %v, want %v", + reply.Proposal.Status, v.sps.ProposalStatus) + } + }) + } +} + +func TestProcessAllVetted(t *testing.T) { + // Setup test environment + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + // Create test data + tokenValid := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5d" + tokenNotHex := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351zzz" + tokenShort := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5" + tokenLong := "3575a65bbc3616c939acf6edf801e1168485dc864efef910034268f695351b5dd" + + // Setup tests + var tests = []struct { + name string + av www.GetAllVetted + want error + }{ + { + "before token not hex", + www.GetAllVetted{ + Before: tokenNotHex, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "before token invalid length short", + www.GetAllVetted{ + Before: tokenShort, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "before token invalid length long", + www.GetAllVetted{ + Before: tokenLong, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "after token not hex", + www.GetAllVetted{ + After: tokenNotHex, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "after token invalid length short", + www.GetAllVetted{ + After: tokenShort, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "after token invalid length long", + www.GetAllVetted{ + After: tokenLong, + }, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + }, + }, + { + "valid before token", + www.GetAllVetted{ + Before: tokenValid, + }, + nil, + }, + { + "valid after token", + www.GetAllVetted{ + After: tokenValid, + }, + nil, + }, + + // XXX only partial test coverage has been added to this route + } + + // Run tests + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + _, err := p.processAllVetted(v.av) + got := errToStr(err) + want := errToStr(v.want) + if got != want { + t.Errorf("got error %v, want %v", + got, want) + } + }) + } +} + +func TestProcessAuthorizeVote(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + usr, id := newUser(t, p, true, false) + + authorize := decredplugin.AuthVoteActionAuthorize + + // Public proposal + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) + + // Proposal not found + propNF := newProposalRecord(t, usr, id, www.PropStatusPublic) + tokenNF := propNF.CensorshipRecord.Token + avNF := newAuthorizeVote(t, tokenNF, propNF.Version, authorize, id) + + // Authorize vote + av := newAuthorizeVote(t, token, prop.Version, authorize, id) + + // Want in reply + s := d.FullIdentity.SignMessage([]byte(av.Signature)) + wantReceipt := hex.EncodeToString(s[:]) + + var tests = []struct { + name string + user *user.User + av www.AuthorizeVote + wantReply *www.AuthorizeVoteReply + wantErr error + }{ + { + "proposal not found", + usr, + avNF, + &www.AuthorizeVoteReply{}, + www.UserError{ + ErrorCode: www.ErrorStatusProposalNotFound, + }, + }, + { + "success", + usr, + av, + &www.AuthorizeVoteReply{ + Action: authorize, + Receipt: wantReceipt, + }, + nil, + }, + } + + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + reply, err := p.processAuthorizeVote(v.av, v.user) + got := errToStr(err) + want := errToStr(v.wantErr) + + // Test if we got expected error + if got != want { + t.Errorf("got error %v, want %v", got, want) + } + + // Test received reply + if err == nil { + if !reflect.DeepEqual(v.wantReply, reply) { + t.Errorf("got reply %v, want %v", reply, v.wantReply) + } + } + }) + } +} + +func TestProcessStartVoteV2(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + usr, id := newUser(t, p, true, true) + usrNotAdmin, _ := newUser(t, p, false, false) + + prop := newProposalRecord(t, usr, id, www.PropStatusPublic) + token := prop.CensorshipRecord.Token + d.AddRecord(t, convertPropToPD(t, prop)) + + sv := newStartVote(t, token, 1, minDuration, www2.VoteTypeStandard, id) + vs := newVoteSummary(t, www.PropVoteStatusAuthorized, []www.VoteOptionResult{}) + p.voteSummarySet(token, vs) + + randomToken, err := util.Random(pd.TokenSize) + if err != nil { + t.Fatal(err) + } + rToken := hex.EncodeToString(randomToken) + + svInvalidToken := newStartVote(t, token, 1, minDuration, + www2.VoteTypeStandard, id) + svInvalidToken.Vote.Token = "" + + svRandomToken := newStartVote(t, token, 1, minDuration, + www2.VoteTypeStandard, id) + svRandomToken.Vote.Token = rToken + + var tests = []struct { + name string + user *user.User + sv www2.StartVote + wantErr error + }{ + { + "user not admin", + usrNotAdmin, + sv, + fmt.Errorf("user is not an admin"), + }, + { + "invalid token", + usr, + svInvalidToken, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorContext: []string{svInvalidToken.Vote.Token}, + }, + }, + { + "proposal not found", + usr, + svRandomToken, + www.UserError{ + ErrorCode: www.ErrorStatusProposalNotFound, + }, + }, + { + "success", + usr, + sv, + nil, + }, + } + + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + _, err := p.processStartVoteV2(v.sv, v.user) + got := errToStr(err) + want := errToStr(v.wantErr) + + if got != want { + t.Errorf("got error %v, want %v", got, want) + } + }) + } +} + +func TestProcessStartVoteRunoffV2(t *testing.T) { + p, cleanup := newTestPoliteiawww(t) + defer cleanup() + + d := newTestPoliteiad(t, p) + defer d.Close() + + // Helper variables + runoff := www2.VoteTypeRunoff + public := www.PropStatusPublic + auth := decredplugin.AuthVoteActionAuthorize + usr, id := newUser(t, p, true, true) + + // Prepare proposal data for testing + rfpProposal := newProposalRecord(t, usr, id, public) + token := rfpProposal.CensorshipRecord.Token + + rfpProposalSubmission1 := newProposalRecord(t, usr, id, public) + sub1Token := rfpProposalSubmission1.CensorshipRecord.Token + rfpProposalSubmission2 := newProposalRecord(t, usr, id, public) + sub2Token := rfpProposalSubmission2.CensorshipRecord.Token + rfpProposalSubmission3 := newProposalRecord(t, usr, id, public) + sub3Token := rfpProposalSubmission3.CensorshipRecord.Token + + extraProposalSubmission := newProposalRecord(t, usr, id, public) + extraToken := extraProposalSubmission.CensorshipRecord.Token + + linkTo := token + linkBy := time.Now().Add(time.Hour * 24 * 30).Unix() + + rfpSubmissions := []*www.ProposalRecord{ + &rfpProposalSubmission1, + &rfpProposalSubmission2, + &rfpProposalSubmission3, + } + + makeProposalRFP(t, &rfpProposal, []string{}, linkBy) + makeProposalRFPSubmissions(t, rfpSubmissions, linkTo) + makeProposalRFPSubmissions(t, []*www.ProposalRecord{&extraProposalSubmission}, + extraToken) + + badRFPProposal := newProposalRecord(t, usr, id, public) + + d.AddRecord(t, convertPropToPD(t, rfpProposal)) + d.AddRecord(t, convertPropToPD(t, rfpProposalSubmission1)) + d.AddRecord(t, convertPropToPD(t, rfpProposalSubmission2)) + d.AddRecord(t, convertPropToPD(t, rfpProposalSubmission3)) + d.AddRecord(t, convertPropToPD(t, extraProposalSubmission)) + d.AddRecord(t, convertPropToPD(t, badRFPProposal)) + + // Prepare data for the route payload + sub1AuthVote := newAuthorizeVoteV2(t, sub1Token, "1", auth, id) + sub2AuthVote := newAuthorizeVoteV2(t, sub2Token, "1", auth, id) + sub3AuthVote := newAuthorizeVoteV2(t, sub3Token, "1", auth, id) + + sub1StartVote := newStartVote(t, sub1Token, 1, minDuration, runoff, id) + sub2StartVote := newStartVote(t, sub2Token, 1, minDuration, runoff, id) + sub3StartVote := newStartVote(t, sub3Token, 1, minDuration, runoff, id) + + // Valid start vote runoff + authVotes := []www2.AuthorizeVote{ + sub1AuthVote, + sub2AuthVote, + sub3AuthVote, + } + startVotes := []www2.StartVote{ + sub1StartVote, + sub2StartVote, + sub3StartVote, + } + svRunoff := newStartVoteRunoff(t, token, authVotes, startVotes) + + // Start vote not matching authorize + avNotMatch := []www2.AuthorizeVote{ + sub2AuthVote, + sub3AuthVote, + } + svNotMatch := []www2.StartVote{ + sub1StartVote, + sub2StartVote, + sub3StartVote, + } + svRunoffNotMatch := newStartVoteRunoff(t, token, avNotMatch, svNotMatch) + + // Start vote not matching authorize + avNotMatch2 := []www2.AuthorizeVote{ + sub1AuthVote, + sub2AuthVote, + sub3AuthVote, + } + svNotMatch2 := []www2.StartVote{ + sub2StartVote, + sub3StartVote, + } + svRunoffNotMatch2 := newStartVoteRunoff(t, token, avNotMatch2, svNotMatch2) + + // Empty auth and start votes + avEmpty := []www2.AuthorizeVote{} + svEmpty := []www2.StartVote{} + svRunoffEmpty := newStartVoteRunoff(t, token, avEmpty, svEmpty) + + // Invalid token in start votes + sub1AvInvalid := sub1AuthVote + sub1AvInvalid.Token = "invalid" + sub1SvInvalid := sub1StartVote + sub1SvInvalid.Vote.Token = "invalid" + avInvalidToken := []www2.AuthorizeVote{ + sub1AvInvalid, + } + svInvalidToken := []www2.StartVote{ + sub1SvInvalid, + } + svRunoffInvalidToken := newStartVoteRunoff(t, token, avInvalidToken, + svInvalidToken) + + // Proposal submission record not found + randomToken, err := util.Random(pd.TokenSize) + if err != nil { + t.Fatal(err) + } + rToken := hex.EncodeToString(randomToken) + sub1AvNotFound := sub1AuthVote + sub1AvNotFound.Token = rToken + sub1SvNotFound := sub1StartVote + sub1SvNotFound.Vote.Token = rToken + avNotFound := []www2.AuthorizeVote{ + sub1AvNotFound, + } + svNotFound := []www2.StartVote{ + sub1SvNotFound, + } + svRunoffNotFound := newStartVoteRunoff(t, token, avNotFound, svNotFound) + + // RFP Proposal record not found + svRunoffRFPNotFound := newStartVoteRunoff(t, rToken, authVotes, startVotes) + + // Empty linked from for RFP proposal + badToken := badRFPProposal.CensorshipRecord.Token + svRunoffBadRFP := newStartVoteRunoff(t, badToken, authVotes, startVotes) + + // Extra StartVote on the route payload + ave := newAuthorizeVoteV2(t, extraToken, "1", auth, id) + sve := newStartVote(t, extraToken, 1, minDuration, runoff, id) + avExtra := authVotes + avExtra = append(avExtra, ave) + svExtra := startVotes + svExtra = append(svExtra, sve) + svRunoffExtraSv := newStartVoteRunoff(t, token, avExtra, svExtra) + + // Missing StartVote from one of the rfp submissions + avMissing := []www2.AuthorizeVote{ + sub1AuthVote, + sub2AuthVote, + } + svMissing := []www2.StartVote{ + sub1StartVote, + sub2StartVote, + } + svRunoffMissingSv := newStartVoteRunoff(t, token, avMissing, svMissing) + + var tests = []struct { + name string + user *user.User + sv www2.StartVoteRunoff + want error + }{ + { + "start vote not matching authorize vote", + usr, + svRunoffNotMatch, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidRunoffVote, + ErrorContext: []string{ + fmt.Sprintf("start vote found without matching authorize"+ + " vote %v", sub1StartVote.Vote.Token), + }, + }, + }, + { + "authorize vote not matching start vote", + usr, + svRunoffNotMatch2, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidRunoffVote, + ErrorContext: []string{ + fmt.Sprintf("authorize vote found without matching start"+ + " vote %v", sub1AuthVote.Token), + }, + }, + }, + { + "empty authorize and start vote entries", + usr, + svRunoffEmpty, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidRunoffVote, + ErrorContext: []string{"start votes and authorize votes cannot " + + "be empty"}, + }, + }, + { + "invalid proposal token in start vote", + usr, + svRunoffInvalidToken, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidCensorshipToken, + ErrorContext: []string{"invalid"}, + }, + }, + { + "proposal submission record not found", + usr, + svRunoffNotFound, + www.UserError{ + ErrorCode: www.ErrorStatusProposalNotFound, + ErrorContext: []string{rToken}, + }, + }, + { + "RFP proposal record not found", + usr, + svRunoffRFPNotFound, + www.UserError{ + ErrorCode: www.ErrorStatusProposalNotFound, + ErrorContext: []string{rToken}, + }, + }, + { + "no linked proposals to the RFP", + usr, + svRunoffBadRFP, + www.UserError{ + ErrorCode: www.ErrorStatusNoLinkedProposals, + }, + }, + { + "extra StartVote on the route payload", + usr, + svRunoffExtraSv, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidRunoffVote, + ErrorContext: []string{ + fmt.Sprintf("invalid start vote submission: %v", + extraToken), + }, + }, + }, + { + "missing StartVote for one of the rfp submissions", + usr, + svRunoffMissingSv, + www.UserError{ + ErrorCode: www.ErrorStatusInvalidRunoffVote, + ErrorContext: []string{ + fmt.Sprintf("missing start vote for rfp submission: %v", + sub3StartVote.Vote.Token), + }, + }, + }, + { + "valid start runoff vote", + usr, + svRunoff, + nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := p.processStartVoteRunoffV2(test.sv, test.user) + + if err != nil { + // Validate error code + gotErrCode := err.(www.UserError).ErrorCode + wantErrCode := test.want.(www.UserError).ErrorCode + + if gotErrCode != wantErrCode { + t.Errorf("got error code %v, want %v", + gotErrCode, wantErrCode) + } + // Validate error context + gotErrContext := err.(www.UserError).ErrorContext + wantErrContext := test.want.(www.UserError).ErrorContext + hasContext := len(gotErrContext) > 0 && len(wantErrContext) > 0 + + if hasContext && (gotErrContext[0] != wantErrContext[0]) { + t.Errorf("got error context '%v', want '%v'", + gotErrContext[0], wantErrContext[0]) + } + } + }) + } +} + +func TestVerifyStatusChange(t *testing.T) { + invalid := www.PropStatusInvalid + notFound := www.PropStatusNotFound + notReviewed := www.PropStatusNotReviewed + censored := www.PropStatusCensored + public := www.PropStatusPublic + unreviewedChanges := www.PropStatusUnreviewedChanges + abandoned := www.PropStatusAbandoned + + // Setup tests + var tests = []struct { + name string + current www.PropStatusT + next www.PropStatusT + wantError bool + }{ + {"not reviewed to invalid", notReviewed, invalid, true}, + {"not reviewed to not found", notReviewed, notFound, true}, + {"not reviewed to censored", notReviewed, censored, false}, + {"not reviewed to public", notReviewed, public, false}, + {"not reviewed to unreviewed changes", notReviewed, unreviewedChanges, + true}, + {"not reviewed to abandoned", notReviewed, abandoned, true}, + {"censored to invalid", censored, invalid, true}, + {"censored to not found", censored, notFound, true}, + {"censored to not reviewed", censored, notReviewed, true}, + {"censored to public", censored, public, true}, + {"censored to unreviewed changes", censored, unreviewedChanges, true}, + {"censored to abandoned", censored, abandoned, true}, + {"public to invalid", public, invalid, true}, + {"public to not found", public, notFound, true}, + {"public to not reviewed", public, notReviewed, true}, + {"public to censored", public, censored, true}, + {"public to unreviewed changes", public, unreviewedChanges, true}, + {"public to abandoned", public, abandoned, false}, + {"unreviewed changes to invalid", unreviewedChanges, invalid, true}, + {"unreviewed changes to not found", unreviewedChanges, notFound, true}, + {"unreviewed changes to not reviewed", unreviewedChanges, notReviewed, + true}, + {"unreviewed changes to censored", unreviewedChanges, censored, false}, + {"unreviewed changes to public", unreviewedChanges, public, false}, + {"unreviewed changes to abandoned", unreviewedChanges, abandoned, true}, + {"abandoned to invalid", abandoned, invalid, true}, + {"abandoned to not found", abandoned, notFound, true}, + {"abandoned to not reviewed", abandoned, notReviewed, true}, + {"abandoned to censored", abandoned, censored, true}, + {"abandoned to public", abandoned, public, true}, + {"abandoned to unreviewed changes", abandoned, unreviewedChanges, true}, + } + + // Run tests + for _, v := range tests { + t.Run(v.name, func(t *testing.T) { + err := verifyStatusChange(v.current, v.next) + got := errToStr(err) + if v.wantError { + want := www.ErrorStatus[www.ErrorStatusInvalidPropStatusTransition] + if got != want { + t.Errorf("got error %v, want %v", + got, want) + } + + // Test case passes + return + } + + if err != nil { + t.Errorf("got error %v, want nil", + got) } }) } diff --git a/politeiawww/testing.go b/politeiawww/testing.go index 8496cc930..1f41e2bcc 100644 --- a/politeiawww/testing.go +++ b/politeiawww/testing.go @@ -5,18 +5,33 @@ package main import ( + "bytes" + "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" + "image" + "image/color" + "image/png" "io/ioutil" + "math/rand" + "net/http" "os" "path/filepath" "testing" "time" "github.com/decred/dcrd/chaincfg" + "github.com/decred/dcrtime/merkle" + "github.com/decred/politeia/decredplugin" + "github.com/decred/politeia/mdstream" + pd "github.com/decred/politeia/politeiad/api/v1" "github.com/decred/politeia/politeiad/api/v1/identity" + "github.com/decred/politeia/politeiad/api/v1/mime" "github.com/decred/politeia/politeiad/cache/testcache" "github.com/decred/politeia/politeiad/testpoliteiad" www "github.com/decred/politeia/politeiawww/api/www/v1" + www2 "github.com/decred/politeia/politeiawww/api/www/v2" "github.com/decred/politeia/politeiawww/user" "github.com/decred/politeia/politeiawww/user/localdb" "github.com/decred/politeia/util" @@ -74,6 +89,152 @@ func addProposalCredits(t *testing.T, p *politeiawww, u *user.User, quantity int } } +func proposalNameRandom(t *testing.T) string { + r, err := util.Random(www.PolicyMinProposalNameLength) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(r) +} + +// merkleRoot returns a hex encoded merkle root of the passed in files and +// metadata. +func merkleRoot(t *testing.T, files []www.File, metadata []www.Metadata) string { + t.Helper() + + digests := make([]*[sha256.Size]byte, 0, len(files)) + for _, f := range files { + // Compute file digest + b, err := base64.StdEncoding.DecodeString(f.Payload) + if err != nil { + t.Fatalf("decode payload for file %v: %v", + f.Name, err) + } + digest := util.Digest(b) + + // Compare against digest that came with the file + d, ok := util.ConvertDigest(f.Digest) + if !ok { + t.Fatalf("invalid digest: file:%v digest:%v", + f.Name, f.Digest) + } + if !bytes.Equal(digest, d[:]) { + t.Fatalf("digests do not match for file %v", + f.Name) + } + + // Digest is valid + digests = append(digests, &d) + } + + for _, v := range metadata { + b, err := base64.StdEncoding.DecodeString(v.Payload) + if err != nil { + t.Fatalf("decode payload for metadata %v: %v", + v.Hint, err) + } + digest := util.Digest(b) + d, ok := util.ConvertDigest(v.Digest) + if !ok { + t.Fatalf("invalid digest: metadata:%v digest:%v", + v.Hint, v.Digest) + } + if !bytes.Equal(digest, d[:]) { + t.Fatalf("digests do not match for metadata %v", + v.Hint) + } + + // Digest is valid + digests = append(digests, &d) + } + + // Compute merkle root + return hex.EncodeToString(merkle.Root(digests)[:]) +} + +// createFilePNG creates a File that contains a png image. The png image is +// blank by default but can be filled in with random rgb colors by setting the +// addColor parameter to true. The png without color will be ~3kB. The png +// with color will be ~2MB. +func createFilePNG(t *testing.T, addColor bool) *www.File { + t.Helper() + + b := new(bytes.Buffer) + img := image.NewRGBA(image.Rect(0, 0, 1000, 500)) + + // Fill in the pixels with random rgb colors in order to + // increase the size of the image. This is used to create an + // image that exceeds the maximum image size policy. + if addColor { + r := rand.New(rand.NewSource(255)) + for y := 0; y < img.Bounds().Max.Y-1; y++ { + for x := 0; x < img.Bounds().Max.X-1; x++ { + a := uint8(r.Float32() * 255) + rgb := uint8(r.Float32() * 255) + img.SetRGBA(x, y, color.RGBA{rgb, rgb, rgb, a}) + } + } + } + + err := png.Encode(b, img) + if err != nil { + t.Fatalf("%v", err) + } + + // Generate a random name + r, err := util.Random(8) + if err != nil { + t.Fatalf("%v", err) + } + + return &www.File{ + Name: hex.EncodeToString(r) + ".png", + MIME: mime.DetectMimeType(b.Bytes()), + Digest: hex.EncodeToString(util.Digest(b.Bytes())), + Payload: base64.StdEncoding.EncodeToString(b.Bytes()), + } +} + +// createFileMD creates a File that contains a markdown file. The markdown +// file is filled with randomly generated data. +func createFileMD(t *testing.T, size int) *www.File { + t.Helper() + + var b bytes.Buffer + r, err := util.Random(size) + if err != nil { + t.Fatalf("%v", err) + } + b.WriteString(base64.StdEncoding.EncodeToString(r) + "\n") + + return &www.File{ + Name: www.PolicyIndexFilename, + MIME: http.DetectContentType(b.Bytes()), + Digest: hex.EncodeToString(util.Digest(b.Bytes())), + Payload: base64.StdEncoding.EncodeToString(b.Bytes()), + } +} + +// createNewProposal computes the merkle root of the given files, signs the +// merkle root with the given identity then returns a NewProposal object. +func createNewProposal(t *testing.T, id *identity.FullIdentity, files []www.File, title string) *www.NewProposal { + t.Helper() + + // Setup metadata + metadata, _ := newProposalMetadata(t, title, "", 0) + + // Compute and sign merkle root + m := merkleRoot(t, files, metadata) + sig := id.SignMessage([]byte(m)) + + return &www.NewProposal{ + Files: files, + Metadata: metadata, + PublicKey: hex.EncodeToString(id.Public.Key[:]), + Signature: hex.EncodeToString(sig[:]), + } +} + // newUser creates a new user using randomly generated user credentials and // inserts the user into the database. The user details and the full user // identity are returned. @@ -158,6 +319,329 @@ func newUser(t *testing.T, p *politeiawww, isVerified, isAdmin bool) (*user.User return usr, fid } +// newFileRandomMD returns a File with the name index.md that contains random +// base64 text. +func newFileRandomMD(t *testing.T) www.File { + t.Helper() + + var b bytes.Buffer + // Add ten lines of random base64 text. + for i := 0; i < 10; i++ { + r, err := util.Random(32) + if err != nil { + t.Fatal(err) + } + b.WriteString(base64.StdEncoding.EncodeToString(r) + "\n") + } + + return www.File{ + Name: "index.md", + MIME: mime.DetectMimeType(b.Bytes()), + Digest: hex.EncodeToString(util.Digest(b.Bytes())), + Payload: base64.StdEncoding.EncodeToString(b.Bytes()), + } +} + +func newStartVote(t *testing.T, token string, v uint32, d uint32, vt www2.VoteT, id *identity.FullIdentity) www2.StartVote { + t.Helper() + + vote := www2.Vote{ + Token: token, + ProposalVersion: v, + Type: vt, + Mask: 0x03, // bit 0 no, bit 1 yes + Duration: d, + QuorumPercentage: 20, + PassPercentage: 60, + Options: []www2.VoteOption{ + { + Id: "no", + Description: "Don't approve proposal", + Bits: 0x01, + }, + { + Id: "yes", + Description: "Approve proposal", + Bits: 0x02, + }, + }, + } + vb, err := json.Marshal(vote) + if err != nil { + t.Fatalf("marshal vote failed: %v %v", err, vote) + } + msg := hex.EncodeToString(util.Digest(vb)) + sig := id.SignMessage([]byte(msg)) + return www2.StartVote{ + Vote: vote, + PublicKey: hex.EncodeToString(id.Public.Key[:]), + Signature: hex.EncodeToString(sig[:]), + } +} + +func newStartVoteCmd(t *testing.T, token string, proposalVersion uint32, d uint32, id *identity.FullIdentity) pd.PluginCommand { + t.Helper() + + sv := newStartVote(t, token, proposalVersion, d, www2.VoteTypeStandard, id) + dsv := convertStartVoteV2ToDecred(sv) + payload, err := decredplugin.EncodeStartVoteV2(dsv) + if err != nil { + t.Fatal(err) + } + + challenge, err := util.Random(pd.ChallengeSize) + if err != nil { + t.Fatal(err) + } + + return pd.PluginCommand{ + Challenge: hex.EncodeToString(challenge), + ID: decredplugin.ID, + Command: decredplugin.CmdStartVote, + CommandID: decredplugin.CmdStartVote + " " + sv.Vote.Token, + Payload: string(payload), + } +} + +func newStartVoteRunoff(t *testing.T, tk string, avs []www2.AuthorizeVote, svs []www2.StartVote) www2.StartVoteRunoff { + t.Helper() + + return www2.StartVoteRunoff{ + Token: tk, + AuthorizeVotes: avs, + StartVotes: svs, + } +} + +func newAuthorizeVoteV2(t *testing.T, token, version, action string, id *identity.FullIdentity) www2.AuthorizeVote { + t.Helper() + + sig := id.SignMessage([]byte(token + version + action)) + return www2.AuthorizeVote{ + Token: token, + Action: action, + PublicKey: hex.EncodeToString(id.Public.Key[:]), + Signature: hex.EncodeToString(sig[:]), + } +} + +func newAuthorizeVote(t *testing.T, token, version, action string, id *identity.FullIdentity) www.AuthorizeVote { + t.Helper() + + sig := id.SignMessage([]byte(token + version + action)) + return www.AuthorizeVote{ + Action: action, + Token: token, + Signature: hex.EncodeToString(sig[:]), + PublicKey: hex.EncodeToString(id.Public.Key[:]), + } +} + +func newAuthorizeVoteCmd(t *testing.T, token, version, action string, id *identity.FullIdentity) pd.PluginCommand { + t.Helper() + + av := newAuthorizeVote(t, token, version, action, id) + dav := convertAuthorizeVoteToDecred(av) + payload, err := decredplugin.EncodeAuthorizeVote(dav) + if err != nil { + t.Fatal(err) + } + + challenge, err := util.Random(pd.ChallengeSize) + if err != nil { + t.Fatal(err) + } + + return pd.PluginCommand{ + Challenge: hex.EncodeToString(challenge), + ID: decredplugin.ID, + Command: decredplugin.CmdAuthorizeVote, + CommandID: decredplugin.CmdAuthorizeVote + " " + av.Token, + Payload: string(payload), + } +} + +func newProposalRecord(t *testing.T, u *user.User, id *identity.FullIdentity, s www.PropStatusT) www.ProposalRecord { + t.Helper() + + f := newFileRandomMD(t) + files := []www.File{f} + name := proposalNameRandom(t) + metadata, _ := newProposalMetadata(t, name, "", 0) + m := merkleRoot(t, files, metadata) + sig := id.SignMessage([]byte(m)) + + var ( + publishedAt int64 + censoredAt int64 + abandonedAt int64 + changeMsg string + ) + + switch s { + case www.PropStatusCensored: + changeMsg = "did not adhere to guidelines" + censoredAt = time.Now().Unix() + case www.PropStatusPublic: + publishedAt = time.Now().Unix() + case www.PropStatusAbandoned: + changeMsg = "no activity" + publishedAt = time.Now().Unix() + abandonedAt = time.Now().Unix() + } + + tokenb, err := util.Random(pd.TokenSize) + if err != nil { + t.Fatal(err) + } + + // The token is typically generated in politeiad. This function + // generates the token locally to make setting up tests easier. + // The censorship record signature is left intentionally blank. + return www.ProposalRecord{ + Name: name, + State: convertPropStatusToState(s), + Status: s, + Timestamp: time.Now().Unix(), + UserId: u.ID.String(), + Username: u.Username, + PublicKey: u.PublicKey(), + Signature: hex.EncodeToString(sig[:]), + NumComments: 0, + Version: "1", + StatusChangeMessage: changeMsg, + PublishedAt: publishedAt, + CensoredAt: censoredAt, + AbandonedAt: abandonedAt, + Files: files, + Metadata: metadata, + CensorshipRecord: www.CensorshipRecord{ + Token: hex.EncodeToString(tokenb), + Merkle: m, + Signature: "", + }, + } +} + +func newProposalMetadata(t *testing.T, name, linkto string, linkby int64) ([]www.Metadata, www.ProposalMetadata) { + t.Helper() + + if name == "" { + // Generate a random name if none was given + name = proposalNameRandom(t) + } + pm := www.ProposalMetadata{ + Name: name, + LinkTo: linkto, + LinkBy: linkby, + } + pmb, err := json.Marshal(pm) + if err != nil { + t.Fatal(err) + } + md := []www.Metadata{ + { + Digest: hex.EncodeToString(util.Digest(pmb)), + Hint: www.HintProposalMetadata, + Payload: base64.StdEncoding.EncodeToString(pmb), + }, + } + return md, pm +} + +func newVoteSummary(t *testing.T, s www.PropVoteStatusT, rs []www.VoteOptionResult) www.VoteSummary { + t.Helper() + + return www.VoteSummary{ + Status: s, + EligibleTickets: 10, + QuorumPercentage: 30, + PassPercentage: 60, + Results: rs, + } +} + +func newVoteOptionV2(t *testing.T, id, desc string, bits uint64) www2.VoteOption { + t.Helper() + + return www2.VoteOption{ + Id: id, + Description: desc, + Bits: bits, + } +} + +func newVoteOptionResult(t *testing.T, id, desc string, bits, votes uint64) www.VoteOptionResult { + t.Helper() + + return www.VoteOptionResult{ + Option: www.VoteOption{ + Id: id, + Description: desc, + Bits: bits, + }, + VotesReceived: votes, + } +} + +func makeProposalRFP(t *testing.T, pr *www.ProposalRecord, linkedfrom []string, linkby int64) { + t.Helper() + + md, _ := newProposalMetadata(t, pr.Name, "", linkby) + pr.LinkBy = linkby + pr.LinkedFrom = linkedfrom + pr.Metadata = md +} + +func makeProposalRFPSubmissions(t *testing.T, prs []*www.ProposalRecord, linkto string) { + t.Helper() + + for _, pr := range prs { + md, _ := newProposalMetadata(t, pr.Name, linkto, 0) + pr.LinkTo = linkto + pr.Metadata = md + } +} + +func convertPropToPD(t *testing.T, p www.ProposalRecord) pd.Record { + t.Helper() + + // Attach ProposalMetadata as a politeiad file + files := convertPropFilesFromWWW(p.Files) + for _, v := range p.Metadata { + switch v.Hint { + case www.HintProposalMetadata: + files = append(files, convertFileFromMetadata(v)) + } + } + + // Create a ProposalGeneralV2 mdstream + md, err := mdstream.EncodeProposalGeneralV2( + mdstream.ProposalGeneralV2{ + Version: mdstream.VersionProposalGeneral, + Timestamp: time.Now().Unix(), + PublicKey: p.PublicKey, + Signature: p.Signature, + }) + if err != nil { + t.Fatal(err) + } + + mdStreams := []pd.MetadataStream{{ + ID: mdstream.IDProposalGeneral, + Payload: string(md), + }} + + return pd.Record{ + Status: convertPropStatusFromWWW(p.Status), + Timestamp: p.Timestamp, + Version: p.Version, + Metadata: mdStreams, + CensorshipRecord: convertPropCensorFromWWW(p.CensorshipRecord), + Files: files, + } +} + // newTestPoliteiawww returns a new politeiawww context that is setup for // testing and a closure that cleans up the test environment when invoked. func newTestPoliteiawww(t *testing.T) (*politeiawww, func()) { @@ -172,10 +656,12 @@ func newTestPoliteiawww(t *testing.T) (*politeiawww, func()) { // Setup config cfg := &config{ - DataDir: dataDir, - PaywallAmount: 1e7, - PaywallXpub: "tpubVobLtToNtTq6TZNw4raWQok35PRPZou53vegZqNubtBTJMMFmuMpWybFCfweJ52N8uZJPZZdHE5SRnBBuuRPfC5jdNstfKjiAs8JtbYG9jx", - TestNet: true, + DataDir: dataDir, + PaywallAmount: 1e7, + PaywallXpub: "tpubVobLtToNtTq6TZNw4raWQok35PRPZou53vegZqNubtBTJMMFmuMpWybFCfweJ52N8uZJPZZdHE5SRnBBuuRPfC5jdNstfKjiAs8JtbYG9jx", + TestNet: true, + VoteDurationMin: 2016, + VoteDurationMax: 4032, } // Setup database