package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// newRepoAddIn is every field the flow can send repo.add.
type newRepoAddIn struct {
Name string `json:"name" validate:"max=64"`
Description string `json:"description"`
Private bool `json:"private"`
Create bool `json:"create"`
Clone string `json:"clone"`
Readme bool `json:"readme"`
License string `json:"license"`
Ignore bool `json:"ignore"`
NoMirror bool `json:"noMirror"`
Git bool `json:"git"`
ZitOnly bool `json:"zitOnly"`
MirrorEvery int `json:"mirrorEvery"`
Namespace string `json:"namespace"`
}
// rigTeams are the teams newRepoRig's reader manages.
var rigTeams []string
type newRepoName struct {
Name string `json:"name"`
}
// newRepoRig is the flow behind a signed-in session, with repo.add and
// repo.import answered by doubles: added records what was asked for,
// and state is what repo.import reports.
func newRepoRig(t *testing.T) (h http.Handler, added *[]newRepoAddIn, state *string) {
t.Helper()
r := ops.NewRegistry()
var got []newRepoAddIn
st := "copying"
if err := ops.Register(r, ops.Op[newRepoAddIn, newRepoName]{
Name: "repo.add", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/repos", Doc: "test double",
Run: func(_ context.Context, in newRepoAddIn) (newRepoName, error) {
got = append(got, in)
return newRepoName{in.Name}, nil
},
}); err != nil {
t.Fatal(err)
}
if err := ops.Register(r, ops.Op[newRepoName, newRepoImport]{
Name: "repo.import", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/repos/import", Doc: "test double",
Run: func(_ context.Context, in newRepoName) (newRepoImport, error) {
return newRepoImport{Name: in.Name, State: st, Bytes: 2048}, nil
},
}); err != nil {
t.Fatal(err)
}
type teamsOut struct {
Teams []map[string]string `json:"teams"`
}
if err := ops.Register(r, ops.Op[struct {
Manages bool `json:"manages"`
}, teamsOut]{
Name: "team.list", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/teams", Doc: "test double",
Run: func(_ context.Context, in struct {
Manages bool `json:"manages"`
}) (teamsOut, error) {
var out teamsOut
for _, t := range rigTeams {
out.Teams = append(out.Teams, map[string]string{"name": t})
}
return out, nil
},
}); err != nil {
t.Fatal(err)
}
// "taken" is the one name somebody already holds.
type nameOut struct {
Name string `json:"name"`
Free bool `json:"free"`
Why string `json:"why,omitempty"`
}
if err := ops.Register(r, ops.Op[newRepoName, nameOut]{
Name: "repo.name", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/repos/name", Doc: "test double",
Run: func(_ context.Context, in newRepoName) (nameOut, error) {
if in.Name == "taken" {
return nameOut{Name: in.Name, Why: `repository "taken" already exists`}, nil
}
return nameOut{Name: in.Name, Free: true}, nil
},
}); err != nil {
t.Fatal(err)
}
// Every page's chrome reads the version.
if err := ops.Register(r, ops.Op[struct{}, struct {
Version string `json:"version"`
}]{
Name: "meta.version", Doc: "test double", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version",
Run: func(context.Context, struct{}) (struct {
Version string `json:"version"`
}, error) {
return struct {
Version string `json:"version"`
}{Version: "t"}, nil
},
}); err != nil {
t.Fatal(err)
}
h = HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "jan", roles: map[string]ops.Auth{"": ops.Token}})})
return h, &got, &st
}
func newRepoDo(h http.Handler, method, path string, form url.Values) *httptest.ResponseRecorder {
var body *strings.Reader
if form != nil {
body = strings.NewReader(form.Encode())
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(method, path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// The start page offers the four paths, and each opens its own setup.
func TestNewRepoStartsWithFourPaths(t *testing.T) {
h, _, _ := newRepoRig(t)
w := newRepoDo(h, "GET", "/new", nil)
for _, kind := range []string{"empty", "import", "github", "fossil"} {
if !strings.Contains(w.Body.String(), `href="/new/`+kind+`"`) {
t.Errorf("FAIL: the start page does not offer %s", kind)
}
page := newRepoDo(h, "GET", "/new/"+kind, nil)
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `data-hook="new-setup" data-kind="`+kind+`"`) {
t.Errorf("FAIL: /new/%s: %d", kind, page.Code)
}
}
if w := newRepoDo(h, "GET", "/new/elsewhere", nil); w.Code != http.StatusNotFound {
t.Errorf("FAIL: an unknown path answered %d", w.Code)
}
}
// Each path sends repo.add what it means, and a copy lands on its
// progress page while an empty repository opens straight away.
func TestNewRepoPathsCreateWhatTheySay(t *testing.T) {
for _, tc := range []struct {
kind, from, source, format, to string
want newRepoAddIn
}{
{"empty", "", "", "", "/r/fresh", newRepoAddIn{Name: "fresh", Create: true, Readme: true}},
{"import", "https://fossil-scm.org/home", "fossil", "", "/new/progress/fresh", newRepoAddIn{Name: "fresh", Clone: "https://fossil-scm.org/home", NoMirror: true}},
{"import", "https://github.com/a/b", "git", "zit", "/new/progress/fresh", newRepoAddIn{Name: "fresh", Clone: "https://github.com/a/b", NoMirror: true, Git: true, ZitOnly: true}},
{"github", "https://github.com/a/b", "", "flat", "/new/progress/fresh", newRepoAddIn{Name: "fresh", Clone: "https://github.com/a/b", Git: true, MirrorEvery: 21600}},
{"fossil", "https://fossil-scm.org/home", "", "zit", "/new/progress/fresh", newRepoAddIn{Name: "fresh", Clone: "https://fossil-scm.org/home", MirrorEvery: 21600}},
} {
h, added, _ := newRepoRig(t)
form := url.Values{"kind": {tc.kind}, "name": {"fresh"}, "from": {tc.from}, "source": {tc.source},
"format": {tc.format}, "every": {"21600"}, "readme": {"true"}, "ack": {"zit"}}
w := newRepoDo(h, "POST", "/new", form)
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != tc.to {
t.Errorf("FAIL: %s/%s: %d to %q, want %s", tc.kind, tc.format, w.Code, w.Header().Get("Location"), tc.to)
continue
}
if len(*added) != 1 || (*added)[0] != tc.want {
t.Errorf("FAIL: %s/%s asked repo.add for %+v, want %+v", tc.kind, tc.format, *added, tc.want)
}
}
}
// A zit-only repository is not made until the review's acknowledgement
// says Fossil cannot read it; the review asks for it.
func TestAZitOnlyRepositoryNeedsItsAcknowledgement(t *testing.T) {
h, added, _ := newRepoRig(t)
form := url.Values{"kind": {"github"}, "from": {"https://github.com/NetBSD/src"}, "format": {"zit"}}
review := newRepoDo(h, "POST", "/new/review", form)
if review.Code != http.StatusOK || !strings.Contains(review.Body.String(), `name="ack" value="zit" required`) {
t.Fatalf("FAIL: the review of a zit-only mirror asks nothing: %d", review.Code)
}
// The name came from the address, and rides to the create.
if !strings.Contains(review.Body.String(), `name="name" value="src"`) {
t.Errorf("FAIL: the review did not name the mirror after its upstream")
}
form.Set("name", "src")
if w := newRepoDo(h, "POST", "/new", form); w.Code != http.StatusBadRequest || len(*added) != 0 {
t.Errorf("FAIL: created without the acknowledgement: %d, %v", w.Code, *added)
}
form.Set("ack", "zit")
if w := newRepoDo(h, "POST", "/new", form); w.Code != http.StatusSeeOther || len(*added) != 1 || !(*added)[0].ZitOnly {
t.Errorf("FAIL: with the acknowledgement: %d, %v", w.Code, *added)
}
}
// Back from the review keeps every answer.
func TestBackFromTheReviewKeepsTheAnswers(t *testing.T) {
h, _, _ := newRepoRig(t)
w := newRepoDo(h, "POST", "/new/github", url.Values{"name": {"src"}, "from": {"https://github.com/NetBSD/src"},
"format": {"zit"}, "every": {"86400"}, "private": {"true"}})
body := w.Body.String()
for _, want := range []string{`name="from" type="url" value="https://github.com/NetBSD/src"`, `value="src"`,
`value="zit" checked`, `value="86400" selected`, `id="private" name="private" value="true" checked`} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: back from the review lost %s", want)
}
}
}
// The progress page streams while the copy runs, and stops asking once
// it has finished; a ready copy opens.
func TestTheProgressPageFollowsTheCopy(t *testing.T) {
h, _, state := newRepoRig(t)
w := newRepoDo(h, "GET", "/new/progress/fresh", nil)
if !strings.Contains(w.Body.String(), `data-state="copying" data-init="@get('/new/progress/fresh/live')"`) {
t.Errorf("FAIL: a copy in progress does not stream")
}
*state = "ready"
w = newRepoDo(h, "GET", "/new/progress/fresh", nil)
body := w.Body.String()
if strings.Contains(body, "/live") || !strings.Contains(body, `href="/r/fresh" data-hook="new-open"`) {
t.Errorf("FAIL: a finished copy still streams, or does not open")
}
}
// The name is checked as it is typed, and again at the review, so a
// taken name is found before the create rather than by it.
func TestATakenNameIsSaidBeforeTheCreate(t *testing.T) {
h, added, _ := newRepoRig(t)
if w := newRepoDo(h, "GET", "/new/name?name=taken", nil); !strings.Contains(w.Body.String(), `data-hook="name-taken"`) {
t.Errorf("FAIL: a taken name was not said as it was typed:\n%s", w.Body.String())
}
if w := newRepoDo(h, "GET", "/new/name?name=fresh", nil); !strings.Contains(w.Body.String(), `data-hook="name-free"`) {
t.Errorf("FAIL: a free name was not said to be free:\n%s", w.Body.String())
}
w := newRepoDo(h, "POST", "/new/review", url.Values{"kind": {"empty"}, "name": {"taken"}})
if w.Code != http.StatusConflict || strings.Contains(w.Body.String(), `action="/new"`) {
t.Errorf("FAIL: the review went ahead with a taken name (%d)", w.Code)
}
if len(*added) != 0 {
t.Error("FAIL: a taken name reached repo.add")
}
// The setup page asks again as soon as it opens with a name in it.
w = newRepoDo(h, "POST", "/new/empty", url.Values{"kind": {"empty"}, "name": {"taken"}})
if !strings.Contains(w.Body.String(), `data-init="@get('/new/name?name=taken&owner=')"`) {
t.Error("FAIL: the setup page does not check the name it opens with")
}
}
// A Git import says check-ins, since its store appears only when it
// completes; a Fossil clone says what has landed on disk.
func TestTheProgressSaysWhatAGitImportCounts(t *testing.T) {
for _, c := range []struct {
in newRepoImport
want string
}{
{newRepoImport{Checkins: 192, Total: 563}, "192"},
{newRepoImport{Bytes: 3 << 20}, "3.0 MB"},
} {
if got := c.in.Copied(); !strings.Contains(got, c.want) {
t.Errorf("FAIL: %+v reads %q, want it to carry %q", c.in, got, c.want)
}
}
if (newRepoImport{}).Copied() == (newRepoImport{Bytes: 1}).Copied() {
t.Error("FAIL: an import that has copied nothing reads like one that has")
}
}
// A reader who manages a team chooses where the repository is made (RFC
// 0025); the choice rides through the review to repo.add. A reader who
// manages none is offered no choice.
func TestTheOwnerIsChosenWhenThereIsAChoice(t *testing.T) {
rigTeams = []string{"crew"}
t.Cleanup(func() { rigTeams = nil })
h, added, _ := newRepoRig(t)
page := newRepoDo(h, "GET", "/new/empty", nil).Body.String()
if !strings.Contains(page, `<select id="new-owner" name="owner"`) || !strings.Contains(page, `<option value="crew">crew</option>`) {
t.Fatalf("FAIL: no owner choice for a reader who manages crew")
}
review := newRepoDo(h, "POST", "/new/review", url.Values{"kind": {"empty"}, "name": {"tools"}, "owner": {"crew"}}).Body.String()
if !strings.Contains(review, `<input type="hidden" name="owner" value="crew">`) || !strings.Contains(review, "crew/tools") {
t.Errorf("FAIL: the review dropped the owner")
}
newRepoDo(h, "POST", "/new", url.Values{"kind": {"empty"}, "name": {"tools"}, "owner": {"crew"}})
if len(*added) != 1 || (*added)[0].Namespace != "crew" {
t.Errorf("FAIL: repo.add was asked %+v, want it in crew", *added)
}
rigTeams = nil
h, _, _ = newRepoRig(t)
if strings.Contains(newRepoDo(h, "GET", "/new/empty", nil).Body.String(), `name="owner"`) {
t.Error("FAIL: a reader who manages no team is offered an owner choice")
}
}