Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 1 | // 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 Rakoczy | 3734795 | 2021-09-02 11:38:42 -0400 | [diff] [blame] | 5 | package relui |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 6 | |
| 7 | import ( |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 8 | "context" |
| 9 | "database/sql" |
Alexander Rakoczy | f740ced | 2021-08-05 17:35:58 -0400 | [diff] [blame] | 10 | "embed" |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 11 | "fmt" |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 12 | "io/ioutil" |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 13 | "log" |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 14 | "net/http" |
| 15 | "net/http/httptest" |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 16 | "net/url" |
| 17 | "os" |
| 18 | "strings" |
| 19 | "sync" |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 20 | "testing" |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 21 | "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 Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 28 | "golang.org/x/build/internal/relui/db" |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 29 | ) |
| 30 | |
Alexander Rakoczy | f740ced | 2021-08-05 17:35:58 -0400 | [diff] [blame] | 31 | // testStatic is our static web server content. |
| 32 | //go:embed testing |
| 33 | var testStatic embed.FS |
| 34 | |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 35 | func TestFileServerHandler(t *testing.T) { |
Alexander Rakoczy | f740ced | 2021-08-05 17:35:58 -0400 | [diff] [blame] | 36 | h := fileServerHandler(testStatic, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 37 | 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 Rakoczy | f740ced | 2021-08-05 17:35:58 -0400 | [diff] [blame] | 55 | path: "/testing/test.css", |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 56 | 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 Rakoczy | f740ced | 2021-08-05 17:35:58 -0400 | [diff] [blame] | 68 | wantHeaders: map[string]string{ |
| 69 | "Content-Type": "text/plain; charset=utf-8", |
| 70 | }, |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 71 | }, |
| 72 | } |
| 73 | for _, c := range cases { |
| 74 | t.Run(c.desc, func(t *testing.T) { |
Alexander Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 75 | req := httptest.NewRequest(http.MethodGet, c.path, nil) |
Alexander Rakoczy | add5b11 | 2020-05-07 10:38:05 -0400 | [diff] [blame] | 76 | 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 Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 100 | |
| 101 | func TestServerHomeHandler(t *testing.T) { |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 102 | 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 Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 116 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 117 | w := httptest.NewRecorder() |
| 118 | |
Alexander Rakoczy | 9076251 | 2021-11-15 11:41:14 -0500 | [diff] [blame] | 119 | s := NewServer(p, NewWorker(p, &PGListener{p}), nil) |
Alexander Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 120 | 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 | |
| 128 | func TestServerNewWorkflowHandler(t *testing.T) { |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 129 | 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 Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 154 | |
Alexander Rakoczy | 9076251 | 2021-11-15 11:41:14 -0500 | [diff] [blame] | 155 | s := NewServer(nil, nil, nil) |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 156 | s.newWorkflowHandler(w, req) |
| 157 | resp := w.Result() |
Alexander Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 158 | |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 159 | if resp.StatusCode != http.StatusOK { |
| 160 | t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK) |
| 161 | } |
| 162 | }) |
Alexander Rakoczy | 0afb23e | 2020-07-01 17:17:00 -0400 | [diff] [blame] | 163 | } |
| 164 | } |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 165 | |
| 166 | func 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 Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 176 | }{ |
| 177 | { |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 178 | desc: "no params", |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 179 | wantCode: http.StatusBadRequest, |
| 180 | }, |
| 181 | { |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 182 | 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 Rakoczy | 71603fa | 2021-09-28 17:44:53 -0400 | [diff] [blame] | 195 | "workflow.params.greeting": []string{"hello"}, |
| 196 | "workflow.params.farewell": []string{"bye"}, |
Alexander Rakoczy | 88204a4 | 2021-09-17 12:15:05 -0400 | [diff] [blame] | 197 | }, |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 198 | wantCode: http.StatusSeeOther, |
| 199 | wantHeaders: map[string]string{ |
| 200 | "Location": "/", |
| 201 | }, |
| 202 | wantWorkflows: []db.Workflow{ |
| 203 | { |
| 204 | ID: uuid.New(), // SameUUIDVariant |
Alexander Rakoczy | 71603fa | 2021-09-28 17:44:53 -0400 | [diff] [blame] | 205 | Params: nullString(`{"farewell": "bye", "greeting": "hello"}`), |
Alexander Rakoczy | 19d8baf | 2021-10-01 14:12:57 -0400 | [diff] [blame] | 206 | Name: nullString(`echo`), |
| 207 | Output: "{}", |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 208 | 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 Rakoczy | 19d8baf | 2021-10-01 14:12:57 -0400 | [diff] [blame] | 219 | rec := httptest.NewRecorder() |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 220 | q := db.New(p) |
| 221 | |
Alexander Rakoczy | 9076251 | 2021-11-15 11:41:14 -0500 | [diff] [blame] | 222 | s := NewServer(p, NewWorker(p, &PGListener{p}), nil) |
Alexander Rakoczy | 19d8baf | 2021-10-01 14:12:57 -0400 | [diff] [blame] | 223 | s.createWorkflowHandler(rec, req) |
| 224 | resp := rec.Result() |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 225 | |
| 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 Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 244 | }) |
| 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 Rakoczy | 19d8baf | 2021-10-01 14:12:57 -0400 | [diff] [blame] | 252 | // truncated except for the migrations table. |
Alexander Rakoczy | cbb5112 | 2021-09-15 20:49:39 -0400 | [diff] [blame] | 253 | func 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 | |
| 280 | var testPoolOnce sync.Once |
| 281 | var 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 |
| 293 | func 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. |
| 324 | func SameUUIDVariant() cmp.Option { |
| 325 | return cmp.Transformer("SameVariant", func(v uuid.UUID) uuid.Variant { |
| 326 | return v.Variant() |
| 327 | }) |
| 328 | } |
| 329 | |
| 330 | func 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. |
| 368 | func nullString(val string) sql.NullString { |
| 369 | return sql.NullString{String: val, Valid: true} |
| 370 | } |
Alexander Rakoczy | 9076251 | 2021-11-15 11:41:14 -0500 | [diff] [blame] | 371 | |
| 372 | func 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 | } |