github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/pi/hooks_test.go (about)

     1  // Copyright (c) 2020-2021 The Decred developers
     2  // Use of this source code is governed by an ISC
     3  // license that can be found in the LICENSE file.
     4  
     5  package pi
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/base64"
    10  	"encoding/hex"
    11  	"encoding/json"
    12  	"errors"
    13  	"image"
    14  	"image/png"
    15  	"net/http"
    16  	"os"
    17  	"strings"
    18  	"testing"
    19  	"time"
    20  
    21  	backend "github.com/decred/politeia/politeiad/backendv2"
    22  	"github.com/decred/politeia/politeiad/backendv2/tstorebe/plugins"
    23  	"github.com/decred/politeia/politeiad/plugins/comments"
    24  	"github.com/decred/politeia/politeiad/plugins/pi"
    25  	"github.com/decred/politeia/util"
    26  )
    27  
    28  func TestIsInCommentTree(t *testing.T) {
    29  	// Setup test data
    30  	oneNodeTree := []comments.Comment{
    31  		{
    32  			CommentID: 1,
    33  			ParentID:  0,
    34  		},
    35  	}
    36  
    37  	twoLeafsTree := []comments.Comment{
    38  		{
    39  			CommentID: 1,
    40  			ParentID:  0,
    41  		},
    42  		{
    43  			CommentID: 2,
    44  			ParentID:  0,
    45  		},
    46  	}
    47  
    48  	threeLevelsTree := []comments.Comment{
    49  		{
    50  			CommentID: 1,
    51  			ParentID:  0,
    52  		},
    53  		{
    54  			CommentID: 2,
    55  			ParentID:  1,
    56  		},
    57  		{
    58  			CommentID: 3,
    59  			ParentID:  2,
    60  		},
    61  		{
    62  			CommentID: 4,
    63  			ParentID:  0,
    64  		},
    65  	}
    66  
    67  	sixLevelsTree := []comments.Comment{
    68  		{
    69  			CommentID: 1,
    70  			ParentID:  0,
    71  		},
    72  		{
    73  			CommentID: 2,
    74  			ParentID:  1,
    75  		},
    76  		{
    77  			CommentID: 3,
    78  			ParentID:  1,
    79  		},
    80  		{
    81  			CommentID: 4,
    82  			ParentID:  2,
    83  		},
    84  		{
    85  			CommentID: 5,
    86  			ParentID:  2,
    87  		},
    88  		{
    89  			CommentID: 6,
    90  			ParentID:  3,
    91  		},
    92  		{
    93  			CommentID: 7,
    94  			ParentID:  5,
    95  		},
    96  		{
    97  			CommentID: 8,
    98  			ParentID:  5,
    99  		},
   100  		{
   101  			CommentID: 9,
   102  			ParentID:  8,
   103  		},
   104  		{
   105  			CommentID: 10,
   106  			ParentID:  9,
   107  		},
   108  	}
   109  
   110  	// Setup tests
   111  	var tests = []struct {
   112  		name string // Test name
   113  		rootID,
   114  		childID uint32
   115  		comments []comments.Comment
   116  		res      bool // Expected result
   117  	}{
   118  		{
   119  			name:     "one node tree true case",
   120  			rootID:   1,
   121  			childID:  1,
   122  			comments: oneNodeTree,
   123  			res:      true,
   124  		},
   125  		{
   126  			name:     "one node tree false case",
   127  			rootID:   0,
   128  			childID:  1,
   129  			comments: oneNodeTree,
   130  			res:      false,
   131  		},
   132  		{
   133  			name:     "two leafs tree false case",
   134  			rootID:   1,
   135  			childID:  2,
   136  			comments: twoLeafsTree,
   137  			res:      false,
   138  		},
   139  		{
   140  			name:     "three levels tree true case",
   141  			rootID:   1,
   142  			childID:  3,
   143  			comments: threeLevelsTree,
   144  			res:      true,
   145  		},
   146  		{
   147  			name:     "three levels tree false case",
   148  			rootID:   1,
   149  			childID:  4,
   150  			comments: threeLevelsTree,
   151  			res:      false,
   152  		},
   153  		{
   154  			name:     "six levels tree true case",
   155  			rootID:   1,
   156  			childID:  10,
   157  			comments: sixLevelsTree,
   158  			res:      true,
   159  		},
   160  		{
   161  			name:     "six levels tree false case",
   162  			rootID:   6,
   163  			childID:  10,
   164  			comments: sixLevelsTree,
   165  			res:      false,
   166  		},
   167  	}
   168  
   169  	// Run tests
   170  	for _, tc := range tests {
   171  		t.Run(tc.name, func(t *testing.T) {
   172  			res := isInCommentTree(tc.rootID, tc.childID, tc.comments)
   173  			if res != tc.res {
   174  				// Unexpected result
   175  				t.Errorf("unexpected result; wanted '%v', got '%v'", tc.res, res)
   176  				return
   177  			}
   178  		})
   179  	}
   180  }
   181  
   182  func TestHookNewRecordPre(t *testing.T) {
   183  	// Setup pi plugin
   184  	p, cleanup := newTestPiPlugin(t)
   185  	defer cleanup()
   186  
   187  	// Run tests
   188  	runProposalFormatTests(t, p.hookNewRecordPre)
   189  }
   190  
   191  func TestHookEditRecordPre(t *testing.T) {
   192  	// Setup pi plugin
   193  	p, cleanup := newTestPiPlugin(t)
   194  	defer cleanup()
   195  
   196  	// Run tests
   197  	runProposalFormatTests(t, p.hookEditRecordPre)
   198  }
   199  
   200  // runProposalFormatTests runs the proposal format tests using the provided
   201  // hook function as the test function. This allows us to run the same set of
   202  // formatting tests of multiple hooks without needing to duplicate the setup
   203  // and error handling code.
   204  func runProposalFormatTests(t *testing.T, hookFn func(string) error) {
   205  	for _, v := range proposalFormatTests(t) {
   206  		t.Run(v.name, func(t *testing.T) {
   207  			// Decode the expected error into a PluginError. If
   208  			// an error is being returned it should always be a
   209  			// PluginError.
   210  			var wantErrorCode pi.ErrorCodeT
   211  			if v.err != nil {
   212  				var pe backend.PluginError
   213  				if !errors.As(v.err, &pe) {
   214  					t.Fatalf("error is not a plugin error '%v'", v.err)
   215  				}
   216  				wantErrorCode = pi.ErrorCodeT(pe.ErrorCode)
   217  			}
   218  
   219  			// Setup payload
   220  			hnrp := plugins.HookNewRecordPre{
   221  				Files: v.files,
   222  			}
   223  			b, err := json.Marshal(hnrp)
   224  			if err != nil {
   225  				t.Fatal(err)
   226  			}
   227  			payload := string(b)
   228  
   229  			// Run test
   230  			err = hookFn(payload)
   231  			switch {
   232  			case v.err != nil && err == nil:
   233  				// Wanted an error but didn't get one
   234  				t.Errorf("want error '%v', got nil",
   235  					pi.ErrorCodes[wantErrorCode])
   236  				return
   237  
   238  			case v.err == nil && err != nil:
   239  				// Wanted success but got an error
   240  				t.Errorf("want error nil, got '%v'", err)
   241  				return
   242  
   243  			case v.err != nil && err != nil:
   244  				// Wanted an error and got an error. Verify that it's
   245  				// the correct error. All errors should be backend
   246  				// plugin errors.
   247  				var gotErr backend.PluginError
   248  				if !errors.As(err, &gotErr) {
   249  					t.Errorf("want plugin error, got '%v'", err)
   250  					return
   251  				}
   252  				if pi.PluginID != gotErr.PluginID {
   253  					t.Errorf("want plugin error with plugin ID '%v', got '%v'",
   254  						pi.PluginID, gotErr.PluginID)
   255  					return
   256  				}
   257  
   258  				gotErrorCode := pi.ErrorCodeT(gotErr.ErrorCode)
   259  				if wantErrorCode != gotErrorCode {
   260  					t.Errorf("want error '%v', got '%v'",
   261  						pi.ErrorCodes[wantErrorCode],
   262  						pi.ErrorCodes[gotErrorCode])
   263  				}
   264  
   265  				// Success; continue to next test
   266  				return
   267  
   268  			case v.err == nil && err == nil:
   269  				// Success; continue to next test
   270  				return
   271  			}
   272  		})
   273  	}
   274  }
   275  
   276  // proposalFormatTest contains the input and output for a test that verifies
   277  // the proposal format meets the pi plugin requirements.
   278  type proposalFormatTest struct {
   279  	name  string         // Test name
   280  	files []backend.File // Input
   281  	err   error          // Expected output
   282  }
   283  
   284  // proposalFormatTests returns a list of tests that verify the files of a
   285  // proposal meet all formatting criteria that the pi plugin requires.
   286  func proposalFormatTests(t *testing.T) []proposalFormatTest {
   287  	t.Helper()
   288  
   289  	// Setup test files
   290  	var (
   291  		index = fileProposalIndex()
   292  
   293  		indexTooLarge backend.File
   294  		png           backend.File
   295  		pngTooLarge   backend.File
   296  	)
   297  
   298  	// Create a index file that is too large
   299  	var sb strings.Builder
   300  	for i := 0; i <= int(pi.SettingTextFileSizeMax); i++ {
   301  		sb.WriteString("a")
   302  	}
   303  	indexTooLarge = file(index.Name, []byte(sb.String()))
   304  
   305  	// Load test fixtures
   306  	b, err := os.ReadFile("testdata/valid.png")
   307  	if err != nil {
   308  		t.Fatal(err)
   309  	}
   310  	png = file("valid.png", b)
   311  
   312  	b, err = os.ReadFile("testdata/too-large.png")
   313  	if err != nil {
   314  		t.Fatal(err)
   315  	}
   316  	pngTooLarge = file("too-large.png", b)
   317  
   318  	// Setup tests
   319  	tests := []proposalFormatTest{
   320  		{
   321  			"text file name invalid",
   322  			[]backend.File{
   323  				{
   324  					Name:    "notallowed.txt",
   325  					MIME:    index.MIME,
   326  					Digest:  index.Digest,
   327  					Payload: index.Payload,
   328  				},
   329  				fileProposalMetadata(t, nil),
   330  			},
   331  			backend.PluginError{
   332  				PluginID:  pi.PluginID,
   333  				ErrorCode: uint32(pi.ErrorCodeTextFileNameInvalid),
   334  			},
   335  		},
   336  		{
   337  			"text file too large",
   338  			[]backend.File{
   339  				indexTooLarge,
   340  				fileProposalMetadata(t, nil),
   341  			},
   342  			backend.PluginError{
   343  				PluginID:  pi.PluginID,
   344  				ErrorCode: uint32(pi.ErrorCodeTextFileSizeInvalid),
   345  			},
   346  		},
   347  		{
   348  			"image file too large",
   349  			[]backend.File{
   350  				fileProposalIndex(),
   351  				fileProposalMetadata(t, nil),
   352  				pngTooLarge,
   353  			},
   354  			backend.PluginError{
   355  				PluginID:  pi.PluginID,
   356  				ErrorCode: uint32(pi.ErrorCodeImageFileSizeInvalid),
   357  			},
   358  		},
   359  		{
   360  			"index file missing",
   361  			[]backend.File{
   362  				fileProposalMetadata(t, nil),
   363  			},
   364  			backend.PluginError{
   365  				PluginID:  pi.PluginID,
   366  				ErrorCode: uint32(pi.ErrorCodeTextFileMissing),
   367  			},
   368  		},
   369  		{
   370  			"too many images",
   371  			[]backend.File{
   372  				fileProposalIndex(),
   373  				fileProposalMetadata(t, nil),
   374  				fileEmptyPNG(t), fileEmptyPNG(t), fileEmptyPNG(t),
   375  				fileEmptyPNG(t), fileEmptyPNG(t), fileEmptyPNG(t),
   376  			},
   377  			backend.PluginError{
   378  				PluginID:  pi.PluginID,
   379  				ErrorCode: uint32(pi.ErrorCodeImageFileCountInvalid),
   380  			},
   381  		},
   382  		{
   383  			"proposal metadata missing",
   384  			[]backend.File{
   385  				fileProposalIndex(),
   386  			},
   387  			backend.PluginError{
   388  				PluginID:  pi.PluginID,
   389  				ErrorCode: uint32(pi.ErrorCodeTextFileMissing),
   390  			},
   391  		},
   392  		{
   393  			"success no attachments",
   394  			[]backend.File{
   395  				fileProposalIndex(),
   396  				fileProposalMetadata(t, nil),
   397  			},
   398  			nil,
   399  		},
   400  		{
   401  			"success with attachments",
   402  			[]backend.File{
   403  				fileProposalIndex(),
   404  				fileProposalMetadata(t, nil),
   405  				png,
   406  			},
   407  			nil,
   408  		},
   409  	}
   410  
   411  	tests = append(tests, proposalNameTests(t)...)
   412  	tests = append(tests, proposalAmountTests(t)...)
   413  	tests = append(tests, proposalStartDateTests(t)...)
   414  	tests = append(tests, proposalEndDateTests(t)...)
   415  	tests = append(tests, proposalDomainTests(t)...)
   416  	return tests
   417  }
   418  
   419  // proposalNameTests returns a list of tests that verify the proposal name
   420  // requirements.
   421  func proposalNameTests(t *testing.T) []proposalFormatTest {
   422  	t.Helper()
   423  
   424  	// Create names to test min and max lengths
   425  	var (
   426  		nameTooShort  string
   427  		nameTooLong   string
   428  		nameMinLength string
   429  		nameMaxLength string
   430  
   431  		b strings.Builder
   432  	)
   433  	for i := 0; i < int(pi.SettingTitleLengthMin)-1; i++ {
   434  		b.WriteString("a")
   435  	}
   436  	nameTooShort = b.String()
   437  	b.Reset()
   438  
   439  	for i := 0; i < int(pi.SettingTitleLengthMax)+1; i++ {
   440  		b.WriteString("a")
   441  	}
   442  	nameTooLong = b.String()
   443  	b.Reset()
   444  
   445  	for i := 0; i < int(pi.SettingTitleLengthMin); i++ {
   446  		b.WriteString("a")
   447  	}
   448  	nameMinLength = b.String()
   449  	b.Reset()
   450  
   451  	for i := 0; i < int(pi.SettingTitleLengthMax); i++ {
   452  		b.WriteString("a")
   453  	}
   454  	nameMaxLength = b.String()
   455  
   456  	// Setup files with an empty proposal name. This is done manually
   457  	// because the function that creates the proposal metadata uses
   458  	// a default value when the name is provided as an empty string.
   459  	filesEmptyName := filesForProposal(t, &pi.ProposalMetadata{
   460  		Name: "",
   461  	})
   462  	for k, v := range filesEmptyName {
   463  		if v.Name == pi.FileNameProposalMetadata {
   464  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   465  			if err != nil {
   466  				t.Fatal(err)
   467  			}
   468  			var pm pi.ProposalMetadata
   469  			err = json.Unmarshal(b, &pm)
   470  			if err != nil {
   471  				t.Fatal(err)
   472  			}
   473  			pm.Name = ""
   474  			b, err = json.Marshal(pm)
   475  			if err != nil {
   476  				t.Fatal(err)
   477  			}
   478  			v.Payload = base64.StdEncoding.EncodeToString(b)
   479  			filesEmptyName[k] = v
   480  		}
   481  	}
   482  
   483  	// errNameInvalid is returned when proposal name validation
   484  	// fails.
   485  	errNameInvalid := backend.PluginError{
   486  		PluginID:  pi.PluginID,
   487  		ErrorCode: uint32(pi.ErrorCodeTitleInvalid),
   488  	}
   489  
   490  	return []proposalFormatTest{
   491  		{
   492  			"name is empty",
   493  			filesEmptyName,
   494  			errNameInvalid,
   495  		},
   496  		{
   497  			"name is too short",
   498  			filesForProposal(t, &pi.ProposalMetadata{
   499  				Name: nameTooShort,
   500  			}),
   501  			errNameInvalid,
   502  		},
   503  		{
   504  			"name is too long",
   505  			filesForProposal(t, &pi.ProposalMetadata{
   506  				Name: nameTooLong,
   507  			}),
   508  			errNameInvalid,
   509  		},
   510  		{
   511  			"name is the min length",
   512  			filesForProposal(t, &pi.ProposalMetadata{
   513  				Name: nameMinLength,
   514  			}),
   515  			nil,
   516  		},
   517  		{
   518  			"name is the max length",
   519  			filesForProposal(t, &pi.ProposalMetadata{
   520  				Name: nameMaxLength,
   521  			}),
   522  			nil,
   523  		},
   524  		{
   525  			"name contains A to Z",
   526  			filesForProposal(t, &pi.ProposalMetadata{
   527  				Name: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
   528  			}),
   529  			nil,
   530  		},
   531  		{
   532  			"name contains a to z",
   533  			filesForProposal(t, &pi.ProposalMetadata{
   534  				Name: "abcdefghijklmnopqrstuvwxyz",
   535  			}),
   536  			nil,
   537  		},
   538  		{
   539  			"name contains 0 to 9",
   540  			filesForProposal(t, &pi.ProposalMetadata{
   541  				Name: "0123456789",
   542  			}),
   543  			nil,
   544  		},
   545  		{
   546  			"name contains supported chars",
   547  			filesForProposal(t, &pi.ProposalMetadata{
   548  				Name: "&.,:;- @+#/()!?\"'",
   549  			}),
   550  			nil,
   551  		},
   552  		{
   553  			"name contains newline",
   554  			filesForProposal(t, &pi.ProposalMetadata{
   555  				Name: "proposal name\n",
   556  			}),
   557  			errNameInvalid,
   558  		},
   559  		{
   560  			"name contains tab",
   561  			filesForProposal(t, &pi.ProposalMetadata{
   562  				Name: "proposal name\t",
   563  			}),
   564  			errNameInvalid,
   565  		},
   566  		{
   567  			"name contains brackets",
   568  			filesForProposal(t, &pi.ProposalMetadata{
   569  				Name: "{proposal name}",
   570  			}),
   571  			errNameInvalid,
   572  		},
   573  		{
   574  			"name is valid lowercase",
   575  			filesForProposal(t, &pi.ProposalMetadata{
   576  				Name: "proposal name",
   577  			}),
   578  			nil,
   579  		},
   580  		{
   581  			"name is valid mixed case",
   582  			filesForProposal(t, &pi.ProposalMetadata{
   583  				Name: "Proposal Name",
   584  			}),
   585  			nil,
   586  		},
   587  	}
   588  }
   589  
   590  // proposalAmountTests returns a list of tests that verify the proposal
   591  // amount requirements.
   592  func proposalAmountTests(t *testing.T) []proposalFormatTest {
   593  	t.Helper()
   594  
   595  	// amount values to test min & max amount limits
   596  	var (
   597  		amountMin      = pi.SettingProposalAmountMin
   598  		amountMax      = pi.SettingProposalAmountMax
   599  		amountTooSmall = amountMin - 1
   600  		amountTooBig   = amountMax + 1
   601  	)
   602  
   603  	// Setup files with a zero amount. This is done manually
   604  	// because the function that creates the proposal metadata uses
   605  	// a default value when the amount is provided as zero.
   606  	filesZeroAmount := filesForProposal(t, &pi.ProposalMetadata{
   607  		Amount: 0,
   608  	})
   609  	for k, v := range filesZeroAmount {
   610  		if v.Name == pi.FileNameProposalMetadata {
   611  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   612  			if err != nil {
   613  				t.Fatal(err)
   614  			}
   615  			var pm pi.ProposalMetadata
   616  			err = json.Unmarshal(b, &pm)
   617  			if err != nil {
   618  				t.Fatal(err)
   619  			}
   620  			pm.Amount = 0
   621  			b, err = json.Marshal(pm)
   622  			if err != nil {
   623  				t.Fatal(err)
   624  			}
   625  			v.Payload = base64.StdEncoding.EncodeToString(b)
   626  			filesZeroAmount[k] = v
   627  		}
   628  	}
   629  
   630  	// errAmountInvalid is returned when proposal amount
   631  	// validation fails.
   632  	errAmountInvalid := backend.PluginError{
   633  		PluginID:  pi.PluginID,
   634  		ErrorCode: uint32(pi.ErrorCodeProposalAmountInvalid),
   635  	}
   636  
   637  	return []proposalFormatTest{
   638  		{
   639  			"amount is zero",
   640  			filesZeroAmount,
   641  			errAmountInvalid,
   642  		},
   643  		{
   644  			"amount too small",
   645  			filesForProposal(t, &pi.ProposalMetadata{
   646  				Amount: amountTooSmall,
   647  			}),
   648  			errAmountInvalid,
   649  		},
   650  		{
   651  			"amount too big",
   652  			filesForProposal(t, &pi.ProposalMetadata{
   653  				Amount: amountTooBig,
   654  			}),
   655  			errAmountInvalid,
   656  		},
   657  		{
   658  			"min amount",
   659  			filesForProposal(t, &pi.ProposalMetadata{
   660  				Amount: amountMin,
   661  			}),
   662  			nil,
   663  		},
   664  		{
   665  			"max amount",
   666  			filesForProposal(t, &pi.ProposalMetadata{
   667  				Amount: amountMax,
   668  			}),
   669  			nil,
   670  		},
   671  	}
   672  }
   673  
   674  // proposalStartDateTests returns a list of tests that verify the proposal
   675  // start date requirements.
   676  func proposalStartDateTests(t *testing.T) []proposalFormatTest {
   677  	t.Helper()
   678  
   679  	// Start date values to test min start date
   680  	var (
   681  		sDateInPast      = time.Now().Unix() - 172800  // two days ago
   682  		sDateInTwoMonths = time.Now().Unix() + 5256000 // in 2 months
   683  	)
   684  
   685  	// Setup files with a zero start date. This is done manually
   686  	// because the function that creates the proposal metadata uses
   687  	// a default value when the start date is provided as zero.
   688  	filesZeroStartDate := filesForProposal(t, &pi.ProposalMetadata{
   689  		StartDate: 0,
   690  	})
   691  	for k, v := range filesZeroStartDate {
   692  		if v.Name == pi.FileNameProposalMetadata {
   693  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   694  			if err != nil {
   695  				t.Fatal(err)
   696  			}
   697  			var pm pi.ProposalMetadata
   698  			err = json.Unmarshal(b, &pm)
   699  			if err != nil {
   700  				t.Fatal(err)
   701  			}
   702  			pm.StartDate = 0
   703  			b, err = json.Marshal(pm)
   704  			if err != nil {
   705  				t.Fatal(err)
   706  			}
   707  			v.Payload = base64.StdEncoding.EncodeToString(b)
   708  			filesZeroStartDate[k] = v
   709  		}
   710  	}
   711  
   712  	// errStartDateInvalid is returned when proposal start date
   713  	// validation fails.
   714  	errStartDateInvalid := backend.PluginError{
   715  		PluginID:  pi.PluginID,
   716  		ErrorCode: uint32(pi.ErrorCodeProposalStartDateInvalid),
   717  	}
   718  
   719  	return []proposalFormatTest{
   720  		{
   721  			"start date in the past",
   722  			filesForProposal(t, &pi.ProposalMetadata{
   723  				StartDate: sDateInPast,
   724  			}),
   725  			errStartDateInvalid,
   726  		},
   727  		{
   728  			"start date is zero",
   729  			filesZeroStartDate,
   730  			errStartDateInvalid,
   731  		},
   732  		{
   733  			"start date in two months",
   734  			filesForProposal(t, &pi.ProposalMetadata{
   735  				StartDate: sDateInTwoMonths,
   736  			}),
   737  			nil,
   738  		},
   739  	}
   740  }
   741  
   742  // proposalEndDateTests returns a list of tests that verify the proposal
   743  // end date requirements.
   744  func proposalEndDateTests(t *testing.T) []proposalFormatTest {
   745  	t.Helper()
   746  
   747  	// End date values to test end date validations.
   748  	var (
   749  		now                  = time.Now().Unix()
   750  		eDateInPast          = now - 172800 // two days ago
   751  		eDateBeforeStartDate = now + 172800 // in two days
   752  		eDateAfterMax        = now +
   753  			pi.SettingProposalEndDateMax + 60 // 1 minute after max
   754  		eDateInEightMonths = now + 21040000 // in 8 months
   755  	)
   756  
   757  	// Setup files with a zero end date. This is done manually
   758  	// because the function that creates the proposal metadata uses
   759  	// a default value when the end date is provided as zero.
   760  	filesZeroEndDate := filesForProposal(t, &pi.ProposalMetadata{
   761  		EndDate: 0,
   762  	})
   763  	for k, v := range filesZeroEndDate {
   764  		if v.Name == pi.FileNameProposalMetadata {
   765  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   766  			if err != nil {
   767  				t.Fatal(err)
   768  			}
   769  			var pm pi.ProposalMetadata
   770  			err = json.Unmarshal(b, &pm)
   771  			if err != nil {
   772  				t.Fatal(err)
   773  			}
   774  			pm.EndDate = 0
   775  			b, err = json.Marshal(pm)
   776  			if err != nil {
   777  				t.Fatal(err)
   778  			}
   779  			v.Payload = base64.StdEncoding.EncodeToString(b)
   780  			filesZeroEndDate[k] = v
   781  		}
   782  	}
   783  
   784  	// errEndDateInvalid is returned when proposal end date
   785  	// validation fails.
   786  	errEndDateInvalid := backend.PluginError{
   787  		PluginID:  pi.PluginID,
   788  		ErrorCode: uint32(pi.ErrorCodeProposalEndDateInvalid),
   789  	}
   790  
   791  	return []proposalFormatTest{
   792  		{
   793  			"end date in the past",
   794  			filesForProposal(t, &pi.ProposalMetadata{
   795  				EndDate: eDateInPast,
   796  			}),
   797  			errEndDateInvalid,
   798  		},
   799  		{
   800  			"start date is zero",
   801  			filesZeroEndDate,
   802  			errEndDateInvalid,
   803  		},
   804  		{
   805  			"end date is before default start date",
   806  			filesForProposal(t, &pi.ProposalMetadata{
   807  				EndDate: eDateBeforeStartDate,
   808  			}),
   809  			errEndDateInvalid,
   810  		},
   811  		{
   812  			"end date is after max",
   813  			filesForProposal(t, &pi.ProposalMetadata{
   814  				EndDate: eDateAfterMax,
   815  			}),
   816  			errEndDateInvalid,
   817  		},
   818  		{
   819  			"end date is in 8 months",
   820  			filesForProposal(t, &pi.ProposalMetadata{
   821  				EndDate: eDateInEightMonths,
   822  			}),
   823  			nil,
   824  		},
   825  	}
   826  }
   827  
   828  // proposalDomainTests returns a list of tests that verify the proposal
   829  // domain requirements.
   830  func proposalDomainTests(t *testing.T) []proposalFormatTest {
   831  	t.Helper()
   832  
   833  	// Domain values to test domain validations.
   834  	var (
   835  		validDomain   = pi.SettingProposalDomains[0]
   836  		invalidDomain = "invalid-domain"
   837  	)
   838  
   839  	// Setup files with an empty domain. This is done manually
   840  	// because the function that creates the proposal metadata uses
   841  	// a default value when the domain is provided as empty string.
   842  	filesEmptyDomain := filesForProposal(t, &pi.ProposalMetadata{
   843  		Domain: "",
   844  	})
   845  	for k, v := range filesEmptyDomain {
   846  		if v.Name == pi.FileNameProposalMetadata {
   847  			b, err := base64.StdEncoding.DecodeString(v.Payload)
   848  			if err != nil {
   849  				t.Fatal(err)
   850  			}
   851  			var pm pi.ProposalMetadata
   852  			err = json.Unmarshal(b, &pm)
   853  			if err != nil {
   854  				t.Fatal(err)
   855  			}
   856  			pm.Domain = ""
   857  			b, err = json.Marshal(pm)
   858  			if err != nil {
   859  				t.Fatal(err)
   860  			}
   861  			v.Payload = base64.StdEncoding.EncodeToString(b)
   862  			filesEmptyDomain[k] = v
   863  		}
   864  	}
   865  
   866  	// errDomainInvalid is returned when proposal domain
   867  	// validation fails.
   868  	errDomainInvalid := backend.PluginError{
   869  		PluginID:  pi.PluginID,
   870  		ErrorCode: uint32(pi.ErrorCodeProposalDomainInvalid),
   871  	}
   872  
   873  	return []proposalFormatTest{
   874  		{
   875  			"invalid domain",
   876  			filesForProposal(t, &pi.ProposalMetadata{
   877  				Domain: invalidDomain,
   878  			}),
   879  			errDomainInvalid,
   880  		},
   881  		{
   882  			"empty domain",
   883  			filesEmptyDomain,
   884  			errDomainInvalid,
   885  		},
   886  		{
   887  			"valid domain",
   888  			filesForProposal(t, &pi.ProposalMetadata{
   889  				Domain: validDomain,
   890  			}),
   891  			nil,
   892  		},
   893  	}
   894  }
   895  
   896  // file returns a backend file for the provided data.
   897  func file(name string, payload []byte) backend.File {
   898  	return backend.File{
   899  		Name:    name,
   900  		MIME:    http.DetectContentType(payload),
   901  		Digest:  hex.EncodeToString(util.Digest(payload)),
   902  		Payload: base64.StdEncoding.EncodeToString(payload),
   903  	}
   904  }
   905  
   906  // fileProposalIndex returns a backend file that contains a proposal index
   907  // file.
   908  func fileProposalIndex() backend.File {
   909  	text := "Hello, world. This is my proposal. Pay me."
   910  	return file(pi.FileNameIndexFile, []byte(text))
   911  }
   912  
   913  // fileProposalMetadata returns a backend file that contains a proposal
   914  // metadata file. The proposal metadata can optionally be provided as an
   915  // argument. Any required proposal metadata fields that are not provided by
   916  // the caller will be filled in using valid defaults.
   917  func fileProposalMetadata(t *testing.T, pm *pi.ProposalMetadata) backend.File {
   918  	t.Helper()
   919  
   920  	// Setup a default proposal metadata
   921  	pmd := &pi.ProposalMetadata{
   922  		Name:      "Test Proposal Name",
   923  		Amount:    2000000,                      // $20k in cents
   924  		StartDate: time.Now().Unix() + 2630000,  // 1 month from now
   925  		EndDate:   time.Now().Unix() + 10368000, // 4 months from now
   926  		Domain:    "development",
   927  	}
   928  
   929  	// Sanity check. Verify that the default domain we used is
   930  	// one of the default domains defined by the pi plugin API.
   931  	var found bool
   932  	for _, v := range pi.SettingProposalDomains {
   933  		if v == pmd.Domain {
   934  			found = true
   935  			break
   936  		}
   937  	}
   938  	if !found {
   939  		t.Fatalf("%v is not a default domain", pmd.Domain)
   940  	}
   941  
   942  	// Overwrite the default values with the caller provided
   943  	// values if they exist.
   944  	if pm == nil {
   945  		pm = &pi.ProposalMetadata{}
   946  	}
   947  	if pm.Name != "" {
   948  		pmd.Name = pm.Name
   949  	}
   950  	if pm.Amount != 0 {
   951  		pmd.Amount = pm.Amount
   952  	}
   953  	if pm.StartDate != 0 {
   954  		pmd.StartDate = pm.StartDate
   955  	}
   956  	if pm.EndDate != 0 {
   957  		pmd.EndDate = pm.EndDate
   958  	}
   959  	if pm.Domain != "" {
   960  		pmd.Domain = pm.Domain
   961  	}
   962  
   963  	// Setup and return the backend file
   964  	b, err := json.Marshal(&pmd)
   965  	if err != nil {
   966  		t.Fatal(err)
   967  	}
   968  
   969  	return file(pi.FileNameProposalMetadata, b)
   970  }
   971  
   972  // fileEmptyPNG returns a backend File that contains an empty PNG image. The
   973  // file name is randomly generated.
   974  func fileEmptyPNG(t *testing.T) backend.File {
   975  	t.Helper()
   976  
   977  	var (
   978  		b   = new(bytes.Buffer)
   979  		img = image.NewRGBA(image.Rect(0, 0, 1000, 500))
   980  	)
   981  	err := png.Encode(b, img)
   982  	if err != nil {
   983  		t.Fatal(err)
   984  	}
   985  	r, err := util.Random(8)
   986  	if err != nil {
   987  		t.Fatal(err)
   988  	}
   989  	name := hex.EncodeToString(r) + ".png"
   990  
   991  	return file(name, b.Bytes())
   992  }
   993  
   994  // filesForProposal returns the backend files for a valid proposal. The
   995  // returned files only include the files required by the pi plugin API. No
   996  // attachment files are included. The caller can pass in additional files that
   997  // will be included in the returned list.
   998  func filesForProposal(t *testing.T, pm *pi.ProposalMetadata, files ...backend.File) []backend.File {
   999  	t.Helper()
  1000  
  1001  	fs := []backend.File{
  1002  		fileProposalIndex(),
  1003  		fileProposalMetadata(t, pm),
  1004  	}
  1005  	fs = append(fs, files...)
  1006  
  1007  	return fs
  1008  }