8786636f7c
Four issues from the review of the initial repospawner service, none of which change the shape of a request or the file terraform-git receives. - Encode status checks as one --check flag per context on the server-to-job hop, so a separator inside a context can no longer turn one context into several; ban commas (and cap lengths) in Validate as well, since a real context never holds one. - Fail a merged request that has waited five minutes for a Woodpecker token that vanished after acceptance, surfacing "woodpecker token unavailable" through the API, instead of warning in the log forever from enabling-ci. Advance now leaves a terminal request alone so the failure sticks. - Hold a per-name lock from the duplicate checks through the store write, so two concurrent submissions of one name cannot both be accepted. - Cap the description at 500 characters and the status checks at 20 contexts of 100 characters each, and mirror the first two caps in the form.
195 lines
6.1 KiB
Go
195 lines
6.1 KiB
Go
package repospec
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestValidate(t *testing.T) {
|
|
base := Request{Name: "widget", Description: "does widgets", StatusChecks: []string{"ci/woodpecker/pr/test"}}
|
|
|
|
cases := []struct {
|
|
name string
|
|
req Request
|
|
fields []string
|
|
wantErr bool
|
|
}{
|
|
{name: "valid", req: base},
|
|
{name: "valid with digits and dashes", req: with(base, func(r *Request) { r.Name = "arr-proxy2" })},
|
|
{name: "missing name", req: with(base, func(r *Request) { r.Name = "" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "uppercase name", req: with(base, func(r *Request) { r.Name = "Widget" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "underscore name", req: with(base, func(r *Request) { r.Name = "wid_get" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "leading dash", req: with(base, func(r *Request) { r.Name = "-widget" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "trailing dash", req: with(base, func(r *Request) { r.Name = "widget-" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "path traversal", req: with(base, func(r *Request) { r.Name = "../etc/passwd" }), fields: []string{"name"}, wantErr: true},
|
|
{name: "over long", req: with(base, func(r *Request) { r.Name = strings.Repeat("a", maxNameLen+1) }), fields: []string{"name"}, wantErr: true},
|
|
{name: "missing description", req: with(base, func(r *Request) { r.Description = "" }), fields: []string{"description"}, wantErr: true},
|
|
{name: "no checks", req: with(base, func(r *Request) { r.StatusChecks = nil }), fields: []string{"status_checks"}, wantErr: true},
|
|
{
|
|
name: "over long description",
|
|
req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen+1) }),
|
|
fields: []string{"description"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "description at the cap",
|
|
req: with(base, func(r *Request) { r.Description = strings.Repeat("d", maxDescriptionLen) }),
|
|
},
|
|
{
|
|
name: "check containing a comma",
|
|
req: with(base, func(r *Request) { r.StatusChecks = []string{"ci/woodpecker/pr/test,ci/woodpecker/pr/build"} }),
|
|
fields: []string{"status_checks"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "check containing a quote",
|
|
req: with(base, func(r *Request) { r.StatusChecks = []string{`ci/"test"`} }),
|
|
fields: []string{"status_checks"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "over long check",
|
|
req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen+1)} }),
|
|
fields: []string{"status_checks"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "check at the cap",
|
|
req: with(base, func(r *Request) { r.StatusChecks = []string{strings.Repeat("c", maxCheckLen)} }),
|
|
},
|
|
{
|
|
name: "too many checks",
|
|
req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks + 1) }),
|
|
fields: []string{"status_checks"},
|
|
wantErr: true,
|
|
},
|
|
{name: "checks at the cap", req: with(base, func(r *Request) { r.StatusChecks = manyChecks(maxChecks) })},
|
|
{
|
|
name: "every field bad at once",
|
|
req: Request{},
|
|
fields: []string{"name", "description", "status_checks"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := tc.req.Validate()
|
|
if tc.wantErr == (err == nil) {
|
|
t.Fatalf("Validate() = %v, wantErr %v", err, tc.wantErr)
|
|
}
|
|
if err == nil {
|
|
return
|
|
}
|
|
var fe FieldErrors
|
|
if !errors.As(err, &fe) {
|
|
t.Fatalf("error %v is not FieldErrors", err)
|
|
}
|
|
if len(fe) != len(tc.fields) {
|
|
t.Fatalf("fields = %v, want exactly %v", fe, tc.fields)
|
|
}
|
|
for _, f := range tc.fields {
|
|
if _, ok := fe[f]; !ok {
|
|
t.Errorf("missing field error for %q; got %v", f, fe)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalize(t *testing.T) {
|
|
r := Request{
|
|
Name: " widget \n",
|
|
Description: " does widgets ",
|
|
StatusChecks: []string{" a ", "", "b", "a", " "},
|
|
}.Normalize()
|
|
|
|
if r.Name != "widget" {
|
|
t.Errorf("Name = %q", r.Name)
|
|
}
|
|
if r.Description != "does widgets" {
|
|
t.Errorf("Description = %q", r.Description)
|
|
}
|
|
want := []string{"a", "b"}
|
|
if len(r.StatusChecks) != len(want) {
|
|
t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want)
|
|
}
|
|
for i := range want {
|
|
if r.StatusChecks[i] != want[i] {
|
|
t.Fatalf("StatusChecks = %v, want %v", r.StatusChecks, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPathsAndBranch(t *testing.T) {
|
|
r := Request{Name: "widget"}
|
|
if got, want := r.ConfigPath(), "config/git.unkin.net/unkin/repository/widget.yaml"; got != want {
|
|
t.Errorf("ConfigPath() = %q, want %q", got, want)
|
|
}
|
|
if got, want := r.BranchName(), "repospawner/widget"; got != want {
|
|
t.Errorf("BranchName() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// golden pins the file terraform-git receives; a drift here changes estate
|
|
// policy for every repository repospawner creates.
|
|
const golden = `description: "Keyboard-centric widget service"
|
|
private: false
|
|
default_branch: "main"
|
|
default_delete_branch_after_merge: true
|
|
default_merge_style: "squash"
|
|
branch_protection:
|
|
- rule_name: "main"
|
|
merge_whitelist_teams:
|
|
- "Owners"
|
|
enable_push: false
|
|
status_check_contexts:
|
|
- "ci/woodpecker/pr/build"
|
|
- "ci/woodpecker/pr/test"
|
|
- "ci/woodpecker/pr/pre-commit"
|
|
approval_whitelist_users:
|
|
- "benvin"
|
|
`
|
|
|
|
func TestRenderYAMLGolden(t *testing.T) {
|
|
got := Request{
|
|
Name: "widget",
|
|
Description: "Keyboard-centric widget service",
|
|
StatusChecks: []string{
|
|
"ci/woodpecker/pr/build",
|
|
"ci/woodpecker/pr/test",
|
|
"ci/woodpecker/pr/pre-commit",
|
|
},
|
|
}.RenderYAML()
|
|
if got != golden {
|
|
t.Errorf("RenderYAML() mismatch\n got:\n%s\nwant:\n%s", got, golden)
|
|
}
|
|
}
|
|
|
|
func TestRenderYAMLQuotesDescription(t *testing.T) {
|
|
got := Request{
|
|
Name: "widget",
|
|
Description: `a "quoted" \ description`,
|
|
StatusChecks: []string{"x"},
|
|
}.RenderYAML()
|
|
want := `description: "a \"quoted\" \\ description"`
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("RenderYAML() did not escape the description; got first line %q", strings.SplitN(got, "\n", 2)[0])
|
|
}
|
|
}
|
|
|
|
func with(r Request, f func(*Request)) Request {
|
|
f(&r)
|
|
return r
|
|
}
|
|
|
|
func manyChecks(n int) []string {
|
|
out := make([]string, 0, n)
|
|
for i := range n {
|
|
out = append(out, "ci/woodpecker/pr/check"+strconv.Itoa(i))
|
|
}
|
|
return out
|
|
}
|