1package workbench23import (4 "io"5 "net/http"6 "net/url"7 "path/filepath"8 "reflect"9 "strings"10 "testing"11)1213func testTargets() Targets {14 return Targets{15 Atlas: "/opt/atlas",16 Dir: "/depot",17 Registry: "/library/bundles",18 Archive: "/depot/archive",19 TileSet: "/depot/tiles",20 TileIndex: "/depot/tiles/index.json",21 }22}2324func TestPlanIsTheCommandAPersonWouldHaveTyped(t *testing.T) {25 targets := testTargets()26 sources := testSources()27 cases := []struct {28 what string29 ask request30 want []string31 }{32 {33 what: "a crawl of one source and target",34 ask: request{Operation: "crawl", Source: "ign", Target: "cyberpunk-2077/night-city"},35 want: []string{"/opt/atlas", "crawl", "--log-json", "-archive", "/depot/archive",36 "-source", "ign", "cyberpunk-2077/night-city"},37 },38 {39 what: "every pyramid the archive holds",40 ask: request{Operation: "tiles"},41 want: []string{"/opt/atlas", "tiles", "--log-json",42 "-archive", "/depot/archive", "-output", "/depot/tiles"},43 },44 {45 what: "composition into the registry the pages read",46 ask: request{Operation: "compose"},47 want: []string{"/opt/atlas", "compose", "--log-json", "-archive", "/depot/archive",48 "-tiles", "/depot/tiles/index.json", "-bundles", "/library/bundles"},49 },50 {51 what: "enrichment of one volume",52 ask: request{Operation: "enrich", Volume: "cyberpunk-2077"},53 want: []string{"/opt/atlas", "enrich", "--log-json", "-archive", "/depot/archive",54 "-tiles", "/depot/tiles/index.json", "-bundles", "/library/bundles", "cyberpunk-2077"},55 },56 {57 what: "a measurement of the registry",58 ask: request{Operation: "measure"},59 want: []string{"/opt/atlas", "measure", "--log-json", "-bundles", "/library/bundles"},60 },61 }62 for _, c := range cases {63 t.Run(c.what, func(t *testing.T) {64 op, err := plan(targets, sources, c.ask)65 if err != nil {66 t.Fatal(err)67 }68 if !reflect.DeepEqual(op.Argv, c.want) {69 t.Errorf("argv %v, want %v", op.Argv, c.want)70 }71 if op.Name != c.ask.Operation {72 t.Errorf("the operation is called %q", op.Name)73 }74 if op.Dir != targets.Dir {75 t.Errorf("the operation runs in %q", op.Dir)76 }77 })78 }79}8081func TestPlanRefusesWhatItCannotRun(t *testing.T) {82 targets := testTargets()83 sources := testSources()84 cases := []struct {85 what string86 targets Targets87 ask request88 says string89 }{90 {"an operation nobody wrote", targets, request{Operation: "levitate"}, "unknown operation"},91 {"a source nobody registered", targets, request{Operation: "crawl", Source: "nowhere", Target: "x"}, "unknown source"},92 {93 "a source with no crawler", targets,94 request{Operation: "crawl", Source: "mapgenie", Target: "cyberpunk-2077"},95 "no crawler registered",96 },97 {98 "a target of the wrong shape", targets,99 request{Operation: "crawl", Source: "ign", Target: "night-city"},100 "needs exactly one slash",101 },102 {103 "a target that would read as a flag", targets,104 request{Operation: "crawl", Source: "nasa-trek", Target: "-rf"},105 "starts with a dash",106 },107 {108 "a volume slug that is not one", targets,109 request{Operation: "measure", Volume: "../etc"}, "volume",110 },111 {112 "a volume on an operation that takes none", targets,113 request{Operation: "tiles", Volume: "tunic"}, "does not take a volume",114 },115 {116 "a crawl with no archive configured",117 Targets{Atlas: "/opt/atlas", Registry: "/library/bundles"},118 request{Operation: "crawl", Source: "ign", Target: "a/b"},119 "no archive configured",120 },121 {122 "anything at all with no binary",123 Targets{Registry: "/library/bundles"},124 request{Operation: "measure"},125 "atlas binary",126 },127 }128 for _, c := range cases {129 t.Run(c.what, func(t *testing.T) {130 op, err := plan(c.targets, sources, c.ask)131 if err == nil {132 t.Fatalf("the plan was accepted: %v", op.Argv)133 }134 if !strings.Contains(err.Error(), c.says) {135 t.Errorf("the refusal is %q, and it should name %q", err, c.says)136 }137 })138 }139}140141func TestTheOperationsPageSaysWhatItCanAndCannotRun(t *testing.T) {142 // A workbench pointed at a registry alone measures and nothing else, and143 // the page says which operations are unavailable rather than letting a144 // person find out by pressing a button.145 partial, err := New(Options{146 Targets: Targets{Atlas: "/opt/atlas", Registry: t.TempDir()},147 Sources: testSources(),148 })149 if err != nil {150 t.Fatal(err)151 }152 _, body := get(t, site(t, partial), "/operations")153 wants(t, "operations", body,154 "crawl", "tiles", "compose", "enrich", "measure",155 "no archive configured", "/operations/run", "op-log")156157 whole, err := New(Options{Targets: testTargets(), Sources: testSources()})158 if err != nil {159 t.Fatal(err)160 }161 _, ready := get(t, site(t, whole), "/operations")162 wants(t, "operations", ready,163 "/depot/archive", "/depot/tiles", "/library/bundles",164 // one form per crawlable source, each with its own target hint165 "IGN Wiki", "NASA Trek", "cyberpunk-2077/night-city", "a body, e.g. mars")166 if strings.Contains(ready, "not configured") {167 t.Error("a fully configured workbench reports something missing")168 }169 // A source with no crawler is not offered a fetch form.170 if strings.Contains(ready, `value="mapgenie"`) {171 t.Error("a source with no crawler was offered a fetch")172 }173}174175// post submits an operation form the way the page does.176func post(t *testing.T, server *http.Client, at string, form url.Values, header ...string) (*http.Response, string) {177 t.Helper()178 request, err := http.NewRequest(http.MethodPost, at, strings.NewReader(form.Encode()))179 if err != nil {180 t.Fatal(err)181 }182 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")183 for pair := 0; pair+1 < len(header); pair += 2 {184 request.Header.Set(header[pair], header[pair+1])185 }186 response, err := server.Do(request)187 if err != nil {188 t.Fatal(err)189 }190 t.Cleanup(func() { response.Body.Close() })191 body, err := io.ReadAll(response.Body)192 if err != nil {193 t.Fatal(err)194 }195 return response, string(body)196}197198func TestAnOperationIsRefusedBeforeItIsStarted(t *testing.T) {199 held, err := New(Options{Targets: testTargets(), Sources: testSources()})200 if err != nil {201 t.Fatal(err)202 }203 server := site(t, held)204 at := server.URL + "/operations/run"205206 // A cross-site POST is turned away before anything is even planned.207 response, _ := post(t, server.Client(), at, url.Values{"op": {"measure"}},208 "Origin", "http://evil.example")209 if response.StatusCode != http.StatusForbidden {210 t.Errorf("a cross-site submission answered %d", response.StatusCode)211 }212 // A same-origin one is not.213 for _, c := range []struct {214 what string215 form url.Values216 want int217 }{218 {"nothing at all", url.Values{}, http.StatusBadRequest},219 {"an operation nobody wrote", url.Values{"op": {"levitate"}}, http.StatusBadRequest},220 {"a bad target", url.Values{"op": {"crawl"}, "source": {"ign"}, "target": {"-rf"}}, http.StatusBadRequest},221 } {222 response, _ := post(t, server.Client(), at, c.form)223 if response.StatusCode != c.want {224 t.Errorf("%s answered %d, want %d", c.what, response.StatusCode, c.want)225 }226 }227228 // One operation at a time: while the slot is held, a submission is229 // refused outright rather than queued behind it.230 release, ok := held.runner.Acquire("compose")231 if !ok {232 t.Fatal("the slot was already held")233 }234 response, body := post(t, server.Client(), at, url.Values{"op": {"measure"}})235 if response.StatusCode != http.StatusConflict {236 t.Fatalf("a second operation answered %d, want 409", response.StatusCode)237 }238 if !strings.Contains(body, "compose") {239 t.Errorf("the refusal does not say what is running: %q", body)240 }241 release()242}243244func TestAnOperationStreamsItsVoiceBackAsRows(t *testing.T) {245 // The binary is a path with nothing at the end of it, so the operation246 // fails at once -- which is the point: its refusal has to arrive through247 // the page as rows rather than as a status code, and that wiring is what248 // is under test here. What a working subprocess sounds like, and that its249 // lines are flushed as they arrive, is oprunner's own suite.250 targets := testTargets()251 targets.Atlas = filepath.Join(t.TempDir(), "atlas")252 targets.Dir = t.TempDir()253254 held, err := New(Options{Targets: targets, Sources: testSources()})255 if err != nil {256 t.Fatal(err)257 }258 server := site(t, held)259260 response, body := post(t, server.Client(), server.URL+"/operations/run", url.Values{"op": {"measure"}})261 if response.StatusCode != http.StatusOK {262 t.Fatalf("the operation answered %d: %s", response.StatusCode, body)263 }264 if got := response.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") {265 t.Errorf("the rows arrived as %q", got)266 }267 if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {268 t.Errorf("the stream is sniffable: %q", got)269 }270 wants(t, "operation stream", body,271 `class="op-row"`, `data-kind="command"`, "measure", "--log-json",272 "/library/bundles", `data-kind="result"`, `data-failed="true"`)273274 // And the slot is free again the moment the operation ends.275 if busy := held.runner.Busy(); busy != "" {276 t.Errorf("the runner still holds %q", busy)277 }278}