golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/relui/web_test.go (about)

     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  
     5  package relui
     6  
     7  import (
     8  	"context"
     9  	"database/sql"
    10  	"embed"
    11  	"flag"
    12  	"fmt"
    13  	"io"
    14  	"log"
    15  	"net/http"
    16  	"net/http/httptest"
    17  	"net/url"
    18  	"os"
    19  	"path"
    20  	"strings"
    21  	"sync"
    22  	"testing"
    23  	"time"
    24  
    25  	"github.com/google/go-cmp/cmp"
    26  	"github.com/google/go-cmp/cmp/cmpopts"
    27  	"github.com/google/uuid"
    28  	"github.com/jackc/pgx/v4"
    29  	"github.com/jackc/pgx/v4/pgxpool"
    30  	"github.com/julienschmidt/httprouter"
    31  	"golang.org/x/build/internal/releasetargets"
    32  	"golang.org/x/build/internal/relui/db"
    33  	"golang.org/x/build/internal/workflow"
    34  )
    35  
    36  // testStatic is our static web server content.
    37  //
    38  //go:embed testing
    39  var testStatic embed.FS
    40  
    41  func TestFileServerHandler(t *testing.T) {
    42  	cases := []struct {
    43  		desc        string
    44  		path        string
    45  		wantCode    int
    46  		wantBody    string
    47  		wantHeaders map[string]string
    48  	}{
    49  		{
    50  			desc:     "sets headers and returns file",
    51  			path:     "/testing/test.css",
    52  			wantCode: http.StatusOK,
    53  			wantBody: "/**\n * Copyright 2022 The Go Authors. All rights reserved.\n " +
    54  				"* Use of this source code is governed by a BSD-style\n " +
    55  				"* license that can be found in the LICENSE file.\n */\n\n.Header { font-size: 10rem; }\n",
    56  			wantHeaders: map[string]string{
    57  				"Content-Type":  "text/css; charset=utf-8",
    58  				"Cache-Control": "no-cache, private, max-age=0",
    59  			},
    60  		},
    61  		{
    62  			desc:     "handles missing file",
    63  			path:     "/foo.js",
    64  			wantCode: http.StatusNotFound,
    65  			wantBody: "404 page not found\n",
    66  			wantHeaders: map[string]string{
    67  				"Content-Type": "text/plain; charset=utf-8",
    68  			},
    69  		},
    70  	}
    71  	for _, c := range cases {
    72  		t.Run(c.desc, func(t *testing.T) {
    73  			req := httptest.NewRequest(http.MethodGet, c.path, nil)
    74  			w := httptest.NewRecorder()
    75  
    76  			m := &metricsRouter{router: httprouter.New()}
    77  			m.ServeFiles("/*filepath", http.FS(testStatic))
    78  			m.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 := io.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  }
   100  
   101  func TestServerHomeHandler(t *testing.T) {
   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(), Name: nullString("test workflow")}
   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{
   112  		WorkflowID: wf.ID,
   113  		Name:       "TestTask",
   114  		Result:     nullString(`{"Filename": "foo.exe"}`),
   115  	}
   116  	if _, err := q.CreateTask(ctx, tp); err != nil {
   117  		t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", tp, err)
   118  	}
   119  
   120  	req := httptest.NewRequest(http.MethodGet, "/", nil)
   121  	w := httptest.NewRecorder()
   122  
   123  	s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil)
   124  
   125  	s.homeHandler(w, req)
   126  	resp := w.Result()
   127  
   128  	if resp.StatusCode != http.StatusOK {
   129  		t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
   130  	}
   131  }
   132  
   133  func TestServerNewWorkflowHandler(t *testing.T) {
   134  	cases := []struct {
   135  		desc     string
   136  		params   url.Values
   137  		wantCode int
   138  	}{
   139  		{
   140  			desc:     "No selection",
   141  			wantCode: http.StatusOK,
   142  		},
   143  		{
   144  			desc:     "valid workflow",
   145  			params:   url.Values{"workflow.name": []string{"echo"}},
   146  			wantCode: http.StatusOK,
   147  		},
   148  		{
   149  			desc:     "invalid workflow",
   150  			params:   url.Values{"workflow.name": []string{"this workflow does not exist"}},
   151  			wantCode: http.StatusOK,
   152  		},
   153  	}
   154  	for _, c := range cases {
   155  		t.Run(c.desc, func(t *testing.T) {
   156  			ctx, cancel := context.WithCancel(context.Background())
   157  			defer cancel()
   158  
   159  			u := url.URL{Path: "/new_workflow", RawQuery: c.params.Encode()}
   160  			req := httptest.NewRequest(http.MethodGet, u.String(), nil)
   161  			w := httptest.NewRecorder()
   162  
   163  			s := NewServer(testDB(ctx, t), NewWorker(NewDefinitionHolder(), nil, nil), nil, SiteHeader{}, nil)
   164  			s.newWorkflowHandler(w, req)
   165  			resp := w.Result()
   166  
   167  			if resp.StatusCode != http.StatusOK {
   168  				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
   169  			}
   170  		})
   171  	}
   172  }
   173  
   174  func TestServerCreateWorkflowHandler(t *testing.T) {
   175  	ctx, cancel := context.WithCancel(context.Background())
   176  	defer cancel()
   177  
   178  	now := time.Now()
   179  	cases := []struct {
   180  		desc          string
   181  		params        url.Values
   182  		wantCode      int
   183  		wantWorkflows []db.Workflow
   184  		wantSchedules []db.Schedule
   185  	}{
   186  		{
   187  			desc:     "no params",
   188  			wantCode: http.StatusBadRequest,
   189  		},
   190  		{
   191  			desc: "invalid workflow name",
   192  			params: url.Values{
   193  				"workflow.name":     []string{"invalid"},
   194  				"workflow.schedule": []string{string(ScheduleImmediate)},
   195  			},
   196  			wantCode: http.StatusBadRequest,
   197  		},
   198  		{
   199  			desc: "missing workflow params",
   200  			params: url.Values{
   201  				"workflow.name":     []string{"echo"},
   202  				"workflow.schedule": []string{string(ScheduleImmediate)},
   203  			},
   204  			wantCode: http.StatusBadRequest,
   205  		},
   206  		{
   207  			desc: "successful creation",
   208  			params: url.Values{
   209  				"workflow.name":            []string{"echo"},
   210  				"workflow.params.greeting": []string{"hello"},
   211  				"workflow.params.farewell": []string{"bye"},
   212  				"workflow.schedule":        []string{string(ScheduleImmediate)},
   213  			},
   214  			wantCode: http.StatusSeeOther,
   215  			wantWorkflows: []db.Workflow{
   216  				{
   217  					ID:        uuid.New(), // SameUUIDVariant
   218  					Params:    nullString(`{"farewell": "bye", "greeting": "hello"}`),
   219  					Name:      nullString(`echo`),
   220  					Output:    "{}",
   221  					CreatedAt: now, // cmpopts.EquateApproxTime
   222  					UpdatedAt: now, // cmpopts.EquateApproxTime
   223  				},
   224  			},
   225  		},
   226  		{
   227  			desc: "successful creation: schedule immediate",
   228  			params: url.Values{
   229  				"workflow.name":            []string{"echo"},
   230  				"workflow.params.greeting": []string{"hello"},
   231  				"workflow.params.farewell": []string{"bye"},
   232  				"workflow.schedule":        []string{string(ScheduleImmediate)},
   233  			},
   234  			wantCode: http.StatusSeeOther,
   235  			wantWorkflows: []db.Workflow{
   236  				{
   237  					ID:        uuid.New(), // SameUUIDVariant
   238  					Params:    nullString(`{"farewell": "bye", "greeting": "hello"}`),
   239  					Name:      nullString(`echo`),
   240  					Output:    "{}",
   241  					CreatedAt: now, // cmpopts.EquateApproxTime
   242  					UpdatedAt: now, // cmpopts.EquateApproxTime
   243  				},
   244  			},
   245  		},
   246  		{
   247  			desc: "successful creation: schedule once",
   248  			params: url.Values{
   249  				"workflow.name":              []string{"echo"},
   250  				"workflow.params.greeting":   []string{"hello"},
   251  				"workflow.params.farewell":   []string{"bye"},
   252  				"workflow.schedule":          []string{string(ScheduleOnce)},
   253  				"workflow.schedule.datetime": []string{now.UTC().AddDate(1, 0, 0).Format(DatetimeLocalLayout)},
   254  			},
   255  			wantCode: http.StatusSeeOther,
   256  			wantSchedules: []db.Schedule{
   257  				{
   258  					WorkflowName:   "echo",
   259  					WorkflowParams: nullString(`{"farewell": "bye", "greeting": "hello"}`),
   260  					Once:           now.UTC().AddDate(1, 0, 0),
   261  					CreatedAt:      now, // cmpopts.EquateApproxTime
   262  					UpdatedAt:      now, // cmpopts.EquateApproxTime
   263  				},
   264  			},
   265  		},
   266  		{
   267  			desc: "successful creation: schedule cron",
   268  			params: url.Values{
   269  				"workflow.name":            []string{"echo"},
   270  				"workflow.params.greeting": []string{"hello"},
   271  				"workflow.params.farewell": []string{"bye"},
   272  				"workflow.schedule":        []string{string(ScheduleCron)},
   273  				"workflow.schedule.cron":   []string{"0 0 1 1 0"},
   274  			},
   275  			wantCode: http.StatusSeeOther,
   276  			wantSchedules: []db.Schedule{
   277  				{
   278  					WorkflowName:   "echo",
   279  					WorkflowParams: nullString(`{"farewell": "bye", "greeting": "hello"}`),
   280  					Spec:           "0 0 1 1 0",
   281  					CreatedAt:      now, // cmpopts.EquateApproxTime
   282  					UpdatedAt:      now, // cmpopts.EquateApproxTime
   283  				},
   284  			},
   285  		},
   286  	}
   287  	for _, c := range cases {
   288  		t.Run(c.desc, func(t *testing.T) {
   289  			p := testDB(ctx, t)
   290  			req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
   291  			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   292  			rec := httptest.NewRecorder()
   293  			q := db.New(p)
   294  
   295  			s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil)
   296  			s.createWorkflowHandler(rec, req)
   297  			resp := rec.Result()
   298  
   299  			if resp.StatusCode != c.wantCode {
   300  				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
   301  				if resp.StatusCode == http.StatusBadRequest {
   302  					b, _ := io.ReadAll(resp.Body)
   303  					t.Logf("resp.Body: \n%v", string(b))
   304  				}
   305  			}
   306  			if c.wantCode == http.StatusBadRequest {
   307  				return
   308  			}
   309  			wfs, err := q.Workflows(ctx)
   310  			if err != nil {
   311  				t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err)
   312  			}
   313  			if diff := cmp.Diff(c.wantWorkflows, wfs, SameUUIDVariant(), cmpopts.EquateApproxTime(time.Minute)); diff != "" {
   314  				t.Fatalf("q.Workflows() mismatch (-want +got):\n%s", diff)
   315  			}
   316  			scheds, err := q.Schedules(ctx)
   317  			if err != nil {
   318  				t.Fatalf("q.Schedules() = %v, %v, wanted no error", scheds, err)
   319  			}
   320  			if diff := cmp.Diff(c.wantSchedules, scheds, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.Schedule{}, "ID")); diff != "" {
   321  				t.Fatalf("q.Schedules() mismatch (-want +got):\n%s", diff)
   322  			}
   323  			if c.wantCode == http.StatusSeeOther && len(c.wantSchedules) == 0 {
   324  				got := resp.Header.Get("Location")
   325  				want := path.Join("/workflows", wfs[0].ID.String())
   326  				if got != want {
   327  					t.Fatalf("resp.Headers.Get(%q) = %q, wanted %q", "Location", got, want)
   328  				}
   329  			}
   330  		})
   331  	}
   332  }
   333  
   334  // resetDB truncates the db connected to in the pgxpool.Pool
   335  // connection.
   336  //
   337  // All tables in the public schema of the connected database will be
   338  // truncated except for the migrations table.
   339  func resetDB(ctx context.Context, t *testing.T, p *pgxpool.Pool) {
   340  	t.Helper()
   341  	tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
   342  	rows, err := p.Query(ctx, tableQuery)
   343  	if err != nil {
   344  		t.Fatalf("p.Query(_, %q, %q) = %v, %v, wanted no error", tableQuery, "public", rows, err)
   345  	}
   346  	defer rows.Close()
   347  	for rows.Next() {
   348  		var name string
   349  		if err := rows.Scan(&name); err != nil {
   350  			t.Fatalf("rows.Scan() = %v, wanted no error", err)
   351  		}
   352  		if name == "migrations" {
   353  			continue
   354  		}
   355  		truncQ := fmt.Sprintf("TRUNCATE %s CASCADE", pgx.Identifier{name}.Sanitize())
   356  		c, err := p.Exec(ctx, truncQ)
   357  		if err != nil {
   358  			t.Fatalf("p.Exec(_, %q) = %v, %v", truncQ, c, err)
   359  		}
   360  	}
   361  	if err := rows.Err(); err != nil {
   362  		log.Fatalf("rows.Err() = %v, wanted no error", err)
   363  	}
   364  }
   365  
   366  var testPoolOnce sync.Once
   367  var testPool *pgxpool.Pool
   368  
   369  var flagTestDB = flag.String("test-pgdatabase", os.Getenv("PGDATABASE"), "postgres database to use for testing")
   370  
   371  // testDB connects, creates, and migrates a database in preparation
   372  // for testing, and returns a connection pool to the prepared
   373  // database.
   374  //
   375  // The connection pool is closed as part of a t.Cleanup handler.
   376  // Database connections are expected to be configured through libpq
   377  // compatible environment variables. If no PGDATABASE is specified,
   378  // relui-test will be used.
   379  //
   380  // https://www.postgresql.org/docs/current/libpq-envars.html
   381  func testDB(ctx context.Context, t *testing.T) *pgxpool.Pool {
   382  	t.Helper()
   383  	if testing.Short() {
   384  		t.Skip("Skipping database tests in short mode.")
   385  	}
   386  	testPoolOnce.Do(func() {
   387  		pgdb := url.QueryEscape(*flagTestDB)
   388  		if pgdb == "" {
   389  			pgdb = "relui-test"
   390  		}
   391  		if err := InitDB(ctx, fmt.Sprintf("database=%v", pgdb)); err != nil {
   392  			t.Skipf("Skipping database integration test: %v", err)
   393  		}
   394  		p, err := pgxpool.Connect(ctx, fmt.Sprintf("database=%v", pgdb))
   395  		if err != nil {
   396  			t.Skipf("Skipping database integration test: %v", err)
   397  		}
   398  		testPool = p
   399  	})
   400  	if testPool == nil {
   401  		t.Skip("Skipping database integration test: testdb = nil. See first error for details.")
   402  		return nil
   403  	}
   404  	t.Cleanup(func() {
   405  		resetDB(context.Background(), t, testPool)
   406  	})
   407  	return testPool
   408  }
   409  
   410  // SameUUIDVariant considers UUIDs equal if they are both the same
   411  // uuid.Variant. Zero-value uuids are considered equal.
   412  func SameUUIDVariant() cmp.Option {
   413  	return cmp.Transformer("SameVariant", func(v uuid.UUID) uuid.Variant {
   414  		return v.Variant()
   415  	})
   416  }
   417  
   418  func TestSameUUIDVariant(t *testing.T) {
   419  	cases := []struct {
   420  		desc string
   421  		x    uuid.UUID
   422  		y    uuid.UUID
   423  		want bool
   424  	}{
   425  		{
   426  			desc: "both set",
   427  			x:    uuid.New(),
   428  			y:    uuid.New(),
   429  			want: true,
   430  		},
   431  		{
   432  			desc: "both unset",
   433  			want: true,
   434  		},
   435  		{
   436  			desc: "just x",
   437  			x:    uuid.New(),
   438  			want: false,
   439  		},
   440  		{
   441  			desc: "just y",
   442  			y:    uuid.New(),
   443  			want: false,
   444  		},
   445  	}
   446  	for _, c := range cases {
   447  		t.Run(c.desc, func(t *testing.T) {
   448  			if got := cmp.Equal(c.x, c.y, SameUUIDVariant()); got != c.want {
   449  				t.Fatalf("cmp.Equal(%v, %v, SameUUIDVariant()) = %t, wanted %t", c.x, c.y, got, c.want)
   450  			}
   451  		})
   452  	}
   453  }
   454  
   455  // nullString returns a sql.NullString for a string.
   456  func nullString(val string) sql.NullString {
   457  	return sql.NullString{String: val, Valid: true}
   458  }
   459  
   460  func TestServerBaseLink(t *testing.T) {
   461  	cases := []struct {
   462  		desc    string
   463  		baseURL string
   464  		target  string
   465  		extras  []string
   466  		want    string
   467  	}{
   468  		{
   469  			desc:   "no baseURL, relative",
   470  			target: "/workflows",
   471  			want:   "/workflows",
   472  		},
   473  		{
   474  			desc:   "no baseURL, absolute",
   475  			target: "https://example.test/something",
   476  			want:   "https://example.test/something",
   477  		},
   478  		{
   479  			desc:    "absolute baseURL, relative",
   480  			baseURL: "https://example.test/releases",
   481  			target:  "/workflows",
   482  			want:    "https://example.test/releases/workflows",
   483  		},
   484  		{
   485  			desc:    "relative baseURL, relative",
   486  			baseURL: "/releases",
   487  			target:  "/workflows",
   488  			want:    "/releases/workflows",
   489  		},
   490  		{
   491  			desc:    "relative baseURL, relative with extras",
   492  			baseURL: "/releases",
   493  			target:  "/workflows",
   494  			extras:  []string{"a-workflow"},
   495  			want:    "/releases/workflows/a-workflow",
   496  		},
   497  		{
   498  			desc:    "absolute baseURL, absolute",
   499  			baseURL: "https://example.test/releases",
   500  			target:  "https://example.test/something",
   501  			want:    "https://example.test/something",
   502  		},
   503  		{
   504  			desc:    "absolute baseURL, absolute with extras",
   505  			baseURL: "https://example.test/releases",
   506  			target:  "https://example.test/something",
   507  			extras:  []string{"else"},
   508  			want:    "https://example.test/something/else",
   509  		},
   510  	}
   511  	for _, c := range cases {
   512  		t.Run(c.desc, func(t *testing.T) {
   513  			base, err := url.Parse(c.baseURL)
   514  			if err != nil {
   515  				t.Fatalf("url.Parse(%q) = %v, %v, wanted no error", c.baseURL, base, err)
   516  			}
   517  			s := &Server{baseURL: base}
   518  
   519  			got := s.BaseLink(c.target, c.extras...)
   520  			if got != c.want {
   521  				t.Errorf("s.BaseLink(%q) = %q, wanted %q", c.target, got, c.want)
   522  			}
   523  		})
   524  	}
   525  }
   526  
   527  func TestServerApproveTaskHandler(t *testing.T) {
   528  	ctx, cancel := context.WithCancel(context.Background())
   529  	defer cancel()
   530  
   531  	hourAgo := time.Now().Add(-1 * time.Hour)
   532  	wfID := uuid.New()
   533  
   534  	cases := []struct {
   535  		desc        string
   536  		params      map[string]string
   537  		wantCode    int
   538  		wantHeaders map[string]string
   539  		want        db.Task
   540  	}{
   541  		{
   542  			desc:     "no params",
   543  			wantCode: http.StatusNotFound,
   544  			want: db.Task{
   545  				WorkflowID: wfID,
   546  				Name:       "approve please",
   547  				CreatedAt:  hourAgo,
   548  				UpdatedAt:  hourAgo,
   549  			},
   550  		},
   551  		{
   552  			desc:     "invalid workflow id",
   553  			params:   map[string]string{"id": "invalid", "name": "greeting"},
   554  			wantCode: http.StatusBadRequest,
   555  			want: db.Task{
   556  				WorkflowID: wfID,
   557  				Name:       "approve please",
   558  				CreatedAt:  hourAgo,
   559  				UpdatedAt:  hourAgo,
   560  			},
   561  		},
   562  		{
   563  			desc:     "wrong workflow id",
   564  			params:   map[string]string{"id": uuid.New().String(), "name": "greeting"},
   565  			wantCode: http.StatusNotFound,
   566  			want: db.Task{
   567  				WorkflowID: wfID,
   568  				Name:       "approve please",
   569  				CreatedAt:  hourAgo,
   570  				UpdatedAt:  hourAgo,
   571  			},
   572  		},
   573  		{
   574  			desc:     "invalid task name",
   575  			params:   map[string]string{"id": wfID.String(), "name": "invalid"},
   576  			wantCode: http.StatusNotFound,
   577  			want: db.Task{
   578  				WorkflowID: wfID,
   579  				Name:       "approve please",
   580  				CreatedAt:  hourAgo,
   581  				UpdatedAt:  hourAgo,
   582  			},
   583  		},
   584  		{
   585  			desc:     "successful approval",
   586  			params:   map[string]string{"id": wfID.String(), "name": "approve please"},
   587  			wantCode: http.StatusSeeOther,
   588  			wantHeaders: map[string]string{
   589  				"Location": path.Join("/workflows", wfID.String()),
   590  			},
   591  			want: db.Task{
   592  				WorkflowID: wfID,
   593  				Name:       "approve please",
   594  				CreatedAt:  hourAgo,
   595  				UpdatedAt:  time.Now(),
   596  				ApprovedAt: sql.NullTime{Time: time.Now(), Valid: true},
   597  			},
   598  		},
   599  	}
   600  	for _, c := range cases {
   601  		t.Run(c.desc, func(t *testing.T) {
   602  			p := testDB(ctx, t)
   603  			q := db.New(p)
   604  
   605  			wf := db.CreateWorkflowParams{
   606  				ID:        wfID,
   607  				Params:    nullString(`{"farewell": "bye", "greeting": "hello"}`),
   608  				Name:      nullString(`echo`),
   609  				CreatedAt: hourAgo,
   610  				UpdatedAt: hourAgo,
   611  			}
   612  			if _, err := q.CreateWorkflow(ctx, wf); err != nil {
   613  				t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err)
   614  			}
   615  			gtg := db.CreateTaskParams{
   616  				WorkflowID: wf.ID,
   617  				Name:       "approve please",
   618  				Finished:   false,
   619  				CreatedAt:  hourAgo,
   620  				UpdatedAt:  hourAgo,
   621  			}
   622  			if _, err := q.CreateTask(ctx, gtg); err != nil {
   623  				t.Fatalf("CreateTask(_, %v) = _, %v, wanted no error", gtg, err)
   624  			}
   625  
   626  			req := httptest.NewRequest(http.MethodPost, path.Join("/workflows/", c.params["id"], "tasks", url.PathEscape(c.params["name"]), "approve"), nil)
   627  			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   628  			rec := httptest.NewRecorder()
   629  			s := NewServer(p, NewWorker(NewDefinitionHolder(), p, &PGListener{DB: p}), nil, SiteHeader{}, nil)
   630  
   631  			s.m.ServeHTTP(rec, req)
   632  			resp := rec.Result()
   633  
   634  			if resp.StatusCode != c.wantCode {
   635  				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
   636  			}
   637  			for k, v := range c.wantHeaders {
   638  				if resp.Header.Get(k) != v {
   639  					t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
   640  				}
   641  			}
   642  			if c.wantCode == http.StatusBadRequest {
   643  				return
   644  			}
   645  			task, err := q.Task(ctx, db.TaskParams{
   646  				WorkflowID: wf.ID,
   647  				Name:       "approve please",
   648  			})
   649  			if err != nil {
   650  				t.Fatalf("q.Task() = %v, %v, wanted no error", task, err)
   651  			}
   652  			if diff := cmp.Diff(c.want, task, cmpopts.EquateApproxTime(time.Minute)); diff != "" {
   653  				t.Fatalf("q.Task() mismatch (-want +got):\n%s", diff)
   654  			}
   655  		})
   656  	}
   657  }
   658  
   659  func TestServerStopWorkflow(t *testing.T) {
   660  	wfID := uuid.New()
   661  	cases := []struct {
   662  		desc        string
   663  		params      map[string]string
   664  		wantCode    int
   665  		wantHeaders map[string]string
   666  		wantLogs    []db.TaskLog
   667  		wantCancel  bool
   668  	}{
   669  		{
   670  			desc:     "no params",
   671  			wantCode: http.StatusMethodNotAllowed,
   672  		},
   673  		{
   674  			desc:     "invalid workflow id",
   675  			params:   map[string]string{"id": "invalid"},
   676  			wantCode: http.StatusBadRequest,
   677  		},
   678  		{
   679  			desc:     "wrong workflow id",
   680  			params:   map[string]string{"id": uuid.New().String()},
   681  			wantCode: http.StatusNotFound,
   682  		},
   683  		{
   684  			desc:     "successful stop",
   685  			params:   map[string]string{"id": wfID.String()},
   686  			wantCode: http.StatusSeeOther,
   687  			wantHeaders: map[string]string{
   688  				"Location": "/",
   689  			},
   690  			wantCancel: true,
   691  		},
   692  	}
   693  	for _, c := range cases {
   694  		t.Run(c.desc, func(t *testing.T) {
   695  			ctx, cancel := context.WithCancel(context.Background())
   696  			defer cancel()
   697  
   698  			req := httptest.NewRequest(http.MethodPost, path.Join("/workflows/", c.params["id"], "stop"), nil)
   699  			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   700  			rec := httptest.NewRecorder()
   701  			worker := NewWorker(NewDefinitionHolder(), nil, nil)
   702  
   703  			wf := &workflow.Workflow{ID: wfID}
   704  			if err := worker.markRunning(wf, cancel); err != nil {
   705  				t.Fatalf("worker.markRunning(%v, %v) = %v, wanted no error", wf, cancel, err)
   706  			}
   707  
   708  			s := NewServer(testDB(ctx, t), worker, nil, SiteHeader{}, nil)
   709  			s.m.ServeHTTP(rec, req)
   710  			resp := rec.Result()
   711  
   712  			if resp.StatusCode != c.wantCode {
   713  				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
   714  			}
   715  			for k, v := range c.wantHeaders {
   716  				if resp.Header.Get(k) != v {
   717  					t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
   718  				}
   719  			}
   720  			if c.wantCancel {
   721  				<-ctx.Done()
   722  				return
   723  			}
   724  			if ctx.Err() != nil {
   725  				t.Errorf("tx.Err() = %v, wanted no error", ctx.Err())
   726  			}
   727  		})
   728  	}
   729  }
   730  
   731  func TestResultDetail(t *testing.T) {
   732  	cases := []struct {
   733  		desc     string
   734  		input    string
   735  		want     *resultDetail
   736  		wantKind string
   737  	}{
   738  		{
   739  			desc:     "string",
   740  			input:    `"hello"`,
   741  			want:     &resultDetail{String: "hello"},
   742  			wantKind: "String",
   743  		},
   744  		{
   745  			desc:     "artifact",
   746  			input:    `{"Filename": "foo.exe", "Target": {"Name": "windows-test"}}`,
   747  			want:     &resultDetail{Artifact: artifact{Filename: "foo.exe", Target: &releasetargets.Target{Name: "windows-test"}}},
   748  			wantKind: "Artifact",
   749  		},
   750  		{
   751  			desc:     "artifact missing target",
   752  			input:    `{"Filename": "foo.exe"}`,
   753  			want:     &resultDetail{Artifact: artifact{Filename: "foo.exe"}},
   754  			wantKind: "Artifact",
   755  		},
   756  		{
   757  			desc:     "nested json string",
   758  			input:    `{"SomeOutput": "hello"}`,
   759  			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {String: "hello"}}},
   760  			wantKind: "Outputs",
   761  		},
   762  		{
   763  			desc:     "nested json complex",
   764  			input:    `{"SomeOutput": {"Filename": "go.exe"}}`,
   765  			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Artifact: artifact{Filename: "go.exe"}}}},
   766  			wantKind: "Outputs",
   767  		},
   768  		{
   769  			desc:  "nested json slice",
   770  			input: `{"SomeOutput": [{"Filename": "go.exe"}]}`,
   771  			want: &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Slice: []*resultDetail{{
   772  				Artifact: artifact{Filename: "go.exe"},
   773  			}}}}},
   774  			wantKind: "Outputs",
   775  		},
   776  		{
   777  			desc:  "nested json output",
   778  			input: `{"SomeOutput": {"OtherOutput": "go.exe", "Next": 123, "Thing": {"foo": "bar"}, "Sauces": ["cranberry", "pizza"]}}`,
   779  			want: &resultDetail{Outputs: map[string]*resultDetail{
   780  				"SomeOutput": {Outputs: map[string]*resultDetail{
   781  					"OtherOutput": {String: "go.exe"},
   782  					"Next":        {Number: 123},
   783  					"Thing":       {Outputs: map[string]*resultDetail{"foo": {String: "bar"}}},
   784  					"Sauces":      {Slice: []*resultDetail{{String: "cranberry"}, {String: "pizza"}}},
   785  				}}}},
   786  			wantKind: "Outputs",
   787  		},
   788  		{
   789  			desc:  "null json",
   790  			input: `null`,
   791  		},
   792  	}
   793  	for _, c := range cases {
   794  		t.Run(c.desc, func(t *testing.T) {
   795  			got := unmarshalResultDetail(c.input)
   796  
   797  			if got.Kind() != c.wantKind {
   798  				t.Errorf("got.Kind() = %q, wanted %q", got.Kind(), c.wantKind)
   799  			}
   800  			if diff := cmp.Diff(c.want, got); diff != "" {
   801  				t.Errorf("unmarshalResultDetail mismatch (-want +got):\n%s", diff)
   802  			}
   803  		})
   804  	}
   805  }