Files
repospawner/internal/repospec/repospec_test.go
T
unkin-agent 8786636f7c
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Address review findings on the initial service
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.
2026-08-30 14:53:27 +10:00

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
}