blob: 52dfe92a5e92a02c352f00fd89e23761dc09b013 [file] [log] [blame]
Alexander Rakoczyadd5b112020-05-07 10:38:05 -04001// Copyright 2020 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
Alexander Rakoczy37347952021-09-02 11:38:42 -04005package relui
Alexander Rakoczyadd5b112020-05-07 10:38:05 -04006
7import (
Alexander Rakoczycbb51122021-09-15 20:49:39 -04008 "context"
9 "database/sql"
Alexander Rakoczyf740ced2021-08-05 17:35:58 -040010 "embed"
Alexander Rakoczycbb51122021-09-15 20:49:39 -040011 "fmt"
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040012 "io/ioutil"
Alexander Rakoczycbb51122021-09-15 20:49:39 -040013 "log"
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040014 "net/http"
15 "net/http/httptest"
Alexander Rakoczycbb51122021-09-15 20:49:39 -040016 "net/url"
17 "os"
18 "strings"
19 "sync"
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040020 "testing"
Alexander Rakoczycbb51122021-09-15 20:49:39 -040021 "time"
22
23 "github.com/google/go-cmp/cmp"
24 "github.com/google/go-cmp/cmp/cmpopts"
25 "github.com/google/uuid"
26 "github.com/jackc/pgx/v4"
27 "github.com/jackc/pgx/v4/pgxpool"
Alexander Rakoczycbb51122021-09-15 20:49:39 -040028 "golang.org/x/build/internal/relui/db"
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040029)
30
Alexander Rakoczyf740ced2021-08-05 17:35:58 -040031// testStatic is our static web server content.
32//go:embed testing
33var testStatic embed.FS
34
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040035func TestFileServerHandler(t *testing.T) {
Alexander Rakoczyf740ced2021-08-05 17:35:58 -040036 h := fileServerHandler(testStatic, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040037 w.Write([]byte("Home"))
38 }))
39
40 cases := []struct {
41 desc string
42 path string
43 wantCode int
44 wantBody string
45 wantHeaders map[string]string
46 }{
47 {
48 desc: "fallback to next handler",
49 path: "/",
50 wantCode: http.StatusOK,
51 wantBody: "Home",
52 },
53 {
54 desc: "sets headers and returns file",
Alexander Rakoczyf740ced2021-08-05 17:35:58 -040055 path: "/testing/test.css",
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040056 wantCode: http.StatusOK,
57 wantBody: ".Header { font-size: 10rem; }\n",
58 wantHeaders: map[string]string{
59 "Content-Type": "text/css; charset=utf-8",
60 "Cache-Control": "no-cache, private, max-age=0",
61 },
62 },
63 {
64 desc: "handles missing file",
65 path: "/foo.js",
66 wantCode: http.StatusNotFound,
67 wantBody: "404 page not found\n",
Alexander Rakoczyf740ced2021-08-05 17:35:58 -040068 wantHeaders: map[string]string{
69 "Content-Type": "text/plain; charset=utf-8",
70 },
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040071 },
72 }
73 for _, c := range cases {
74 t.Run(c.desc, func(t *testing.T) {
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -040075 req := httptest.NewRequest(http.MethodGet, c.path, nil)
Alexander Rakoczyadd5b112020-05-07 10:38:05 -040076 w := httptest.NewRecorder()
77
78 h.ServeHTTP(w, req)
79 resp := w.Result()
80 defer resp.Body.Close()
81
82 if resp.StatusCode != c.wantCode {
83 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
84 }
85 b, err := ioutil.ReadAll(resp.Body)
86 if err != nil {
87 t.Errorf("resp.Body = _, %v, wanted no error", err)
88 }
89 if string(b) != c.wantBody {
90 t.Errorf("resp.Body = %q, %v, wanted %q, %v", b, err, c.wantBody, nil)
91 }
92 for k, v := range c.wantHeaders {
93 if resp.Header.Get(k) != v {
94 t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
95 }
96 }
97 })
98 }
99}
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400100
101func TestServerHomeHandler(t *testing.T) {
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400102 ctx, cancel := context.WithCancel(context.Background())
103 defer cancel()
104 p := testDB(ctx, t)
105
106 q := db.New(p)
107 wf := db.CreateWorkflowParams{ID: uuid.New()}
108 if _, err := q.CreateWorkflow(ctx, wf); err != nil {
109 t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err)
110 }
111 tp := db.CreateTaskParams{WorkflowID: wf.ID, Name: "TestTask"}
112 if _, err := q.CreateTask(ctx, tp); err != nil {
113 t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", tp, err)
114 }
115
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400116 req := httptest.NewRequest(http.MethodGet, "/", nil)
117 w := httptest.NewRecorder()
118
Alexander Rakoczy90762512021-11-15 11:41:14 -0500119 s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400120 s.homeHandler(w, req)
121 resp := w.Result()
122
123 if resp.StatusCode != http.StatusOK {
124 t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
125 }
126}
127
128func TestServerNewWorkflowHandler(t *testing.T) {
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400129 cases := []struct {
130 desc string
131 params url.Values
132 wantCode int
133 }{
134 {
135 desc: "No selection",
136 wantCode: http.StatusOK,
137 },
138 {
139 desc: "valid workflow",
140 params: url.Values{"workflow.name": []string{"echo"}},
141 wantCode: http.StatusOK,
142 },
143 {
144 desc: "invalid workflow",
145 params: url.Values{"workflow.name": []string{"this workflow does not exist"}},
146 wantCode: http.StatusOK,
147 },
148 }
149 for _, c := range cases {
150 t.Run(c.desc, func(t *testing.T) {
151 u := url.URL{Path: "/workflows/new", RawQuery: c.params.Encode()}
152 req := httptest.NewRequest(http.MethodGet, u.String(), nil)
153 w := httptest.NewRecorder()
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400154
Alexander Rakoczy90762512021-11-15 11:41:14 -0500155 s := NewServer(nil, nil, nil)
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400156 s.newWorkflowHandler(w, req)
157 resp := w.Result()
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400158
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400159 if resp.StatusCode != http.StatusOK {
160 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
161 }
162 })
Alexander Rakoczy0afb23e2020-07-01 17:17:00 -0400163 }
164}
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400165
166func TestServerCreateWorkflowHandler(t *testing.T) {
167 ctx, cancel := context.WithCancel(context.Background())
168 defer cancel()
169
170 cases := []struct {
171 desc string
172 params url.Values
173 wantCode int
174 wantHeaders map[string]string
175 wantWorkflows []db.Workflow
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400176 }{
177 {
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400178 desc: "no params",
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400179 wantCode: http.StatusBadRequest,
180 },
181 {
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400182 desc: "invalid workflow name",
183 params: url.Values{"workflow.name": []string{"invalid"}},
184 wantCode: http.StatusBadRequest,
185 },
186 {
187 desc: "missing workflow params",
188 params: url.Values{"workflow.name": []string{"echo"}},
189 wantCode: http.StatusBadRequest,
190 },
191 {
192 desc: "successful creation",
193 params: url.Values{
194 "workflow.name": []string{"echo"},
Alexander Rakoczy71603fa2021-09-28 17:44:53 -0400195 "workflow.params.greeting": []string{"hello"},
196 "workflow.params.farewell": []string{"bye"},
Alexander Rakoczy88204a42021-09-17 12:15:05 -0400197 },
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400198 wantCode: http.StatusSeeOther,
199 wantHeaders: map[string]string{
200 "Location": "/",
201 },
202 wantWorkflows: []db.Workflow{
203 {
204 ID: uuid.New(), // SameUUIDVariant
Alexander Rakoczy71603fa2021-09-28 17:44:53 -0400205 Params: nullString(`{"farewell": "bye", "greeting": "hello"}`),
Alexander Rakoczy19d8baf2021-10-01 14:12:57 -0400206 Name: nullString(`echo`),
207 Output: "{}",
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400208 CreatedAt: time.Now(), // cmpopts.EquateApproxTime
209 UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
210 },
211 },
212 },
213 }
214 for _, c := range cases {
215 t.Run(c.desc, func(t *testing.T) {
216 p := testDB(ctx, t)
217 req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
218 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
Alexander Rakoczy19d8baf2021-10-01 14:12:57 -0400219 rec := httptest.NewRecorder()
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400220 q := db.New(p)
221
Alexander Rakoczy90762512021-11-15 11:41:14 -0500222 s := NewServer(p, NewWorker(p, &PGListener{p}), nil)
Alexander Rakoczy19d8baf2021-10-01 14:12:57 -0400223 s.createWorkflowHandler(rec, req)
224 resp := rec.Result()
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400225
226 if resp.StatusCode != c.wantCode {
227 t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
228 }
229 for k, v := range c.wantHeaders {
230 if resp.Header.Get(k) != v {
231 t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
232 }
233 }
234 if c.wantCode == http.StatusBadRequest {
235 return
236 }
237 wfs, err := q.Workflows(ctx)
238 if err != nil {
239 t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err)
240 }
241 if diff := cmp.Diff(c.wantWorkflows, wfs, SameUUIDVariant(), cmpopts.EquateApproxTime(time.Minute)); diff != "" {
242 t.Fatalf("q.Workflows() mismatch (-want +got):\n%s", diff)
243 }
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400244 })
245 }
246}
247
248// resetDB truncates the db connected to in the pgxpool.Pool
249// connection.
250//
251// All tables in the public schema of the connected database will be
Alexander Rakoczy19d8baf2021-10-01 14:12:57 -0400252// truncated except for the migrations table.
Alexander Rakoczycbb51122021-09-15 20:49:39 -0400253func resetDB(ctx context.Context, t *testing.T, p *pgxpool.Pool) {
254 t.Helper()
255 tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
256 rows, err := p.Query(ctx, tableQuery)
257 if err != nil {
258 t.Fatalf("p.Query(_, %q, %q) = %v, %v, wanted no error", tableQuery, "public", rows, err)
259 }
260 defer rows.Close()
261 for rows.Next() {
262 var name string
263 if err := rows.Scan(&name); err != nil {
264 t.Fatalf("rows.Scan() = %v, wanted no error", err)
265 }
266 if name == "migrations" {
267 continue
268 }
269 truncQ := fmt.Sprintf("TRUNCATE %s CASCADE", pgx.Identifier{name}.Sanitize())
270 c, err := p.Exec(ctx, truncQ)
271 if err != nil {
272 t.Fatalf("p.Exec(_, %q) = %v, %v", truncQ, c, err)
273 }
274 }
275 if err := rows.Err(); err != nil {
276 log.Fatalf("rows.Err() = %v, wanted no error", err)
277 }
278}
279
280var testPoolOnce sync.Once
281var testPool *pgxpool.Pool
282
283// testDB connects, creates, and migrates a database in preparation
284// for testing, and returns a connection pool to the prepared
285// database.
286//
287// The connection pool is closed as part of a t.Cleanup handler.
288// Database connections are expected to be configured through libpq
289// compatible environment variables. If no PGDATABASE is specified,
290// relui-test will be used.
291//
292// https://www.postgresql.org/docs/current/libpq-envars.html
293func testDB(ctx context.Context, t *testing.T) *pgxpool.Pool {
294 t.Helper()
295 if testing.Short() {
296 t.Skip("Skipping database tests in short mode.")
297 }
298 testPoolOnce.Do(func() {
299 pgdb := url.QueryEscape(os.Getenv("PGDATABASE"))
300 if pgdb == "" {
301 pgdb = "relui-test"
302 }
303 if err := InitDB(ctx, fmt.Sprintf("database=%v", pgdb)); err != nil {
304 t.Skipf("Skipping database integration test: %v", err)
305 }
306 p, err := pgxpool.Connect(ctx, fmt.Sprintf("database=%v", pgdb))
307 if err != nil {
308 t.Skipf("Skipping database integration test: %v", err)
309 }
310 testPool = p
311 })
312 if testPool == nil {
313 t.Skip("Skipping database integration test: testdb = nil. See first error for details.")
314 return nil
315 }
316 t.Cleanup(func() {
317 resetDB(context.Background(), t, testPool)
318 })
319 return testPool
320}
321
322// SameUUIDVariant considers UUIDs equal if they are both the same
323// uuid.Variant. Zero-value uuids are considered equal.
324func SameUUIDVariant() cmp.Option {
325 return cmp.Transformer("SameVariant", func(v uuid.UUID) uuid.Variant {
326 return v.Variant()
327 })
328}
329
330func TestSameUUIDVariant(t *testing.T) {
331 cases := []struct {
332 desc string
333 x uuid.UUID
334 y uuid.UUID
335 want bool
336 }{
337 {
338 desc: "both set",
339 x: uuid.New(),
340 y: uuid.New(),
341 want: true,
342 },
343 {
344 desc: "both unset",
345 want: true,
346 },
347 {
348 desc: "just x",
349 x: uuid.New(),
350 want: false,
351 },
352 {
353 desc: "just y",
354 y: uuid.New(),
355 want: false,
356 },
357 }
358 for _, c := range cases {
359 t.Run(c.desc, func(t *testing.T) {
360 if got := cmp.Equal(c.x, c.y, SameUUIDVariant()); got != c.want {
361 t.Fatalf("cmp.Equal(%v, %v, SameUUIDVariant()) = %t, wanted %t", c.x, c.y, got, c.want)
362 }
363 })
364 }
365}
366
367// nullString returns a sql.NullString for a string.
368func nullString(val string) sql.NullString {
369 return sql.NullString{String: val, Valid: true}
370}
Alexander Rakoczy90762512021-11-15 11:41:14 -0500371
372func TestServerBaseLink(t *testing.T) {
373 cases := []struct {
374 desc string
375 baseURL string
376 target string
377 want string
378 }{
379 {
380 desc: "no baseURL, relative",
381 target: "/workflows",
382 want: "/workflows",
383 },
384 {
385 desc: "no baseURL, absolute",
386 target: "https://example.test/something",
387 want: "https://example.test/something",
388 },
389 {
390 desc: "absolute baseURL, relative",
391 baseURL: "https://example.test/releases",
392 target: "/workflows",
393 want: "https://example.test/releases/workflows",
394 },
395 {
396 desc: "relative baseURL, relative",
397 baseURL: "/releases",
398 target: "/workflows",
399 want: "/releases/workflows",
400 },
401 {
402 desc: "absolute baseURL, absolute",
403 baseURL: "https://example.test/releases",
404 target: "https://example.test/something",
405 want: "https://example.test/something",
406 },
407 }
408 for _, c := range cases {
409 t.Run(c.desc, func(t *testing.T) {
410 base, err := url.Parse(c.baseURL)
411 if err != nil {
412 t.Fatalf("url.Parse(%q) = %v, %v, wanted no error", c.baseURL, base, err)
413 }
414 s := NewServer(nil, nil, base)
415
416 got := s.BaseLink(c.target)
417 if got != c.want {
418 t.Errorf("s.BaseLink(%q) = %q, wanted %q", c.target, got, c.want)
419 }
420 })
421 }
422}