go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/routes_test.go (about)

     1  // Copyright 2016 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package frontend
    16  
    17  import (
    18  	"context"
    19  	"flag"
    20  	"fmt"
    21  	"html/template"
    22  	"net/http"
    23  	"net/http/httptest"
    24  	"net/url"
    25  	"os"
    26  	"path/filepath"
    27  	"regexp"
    28  	"strings"
    29  	"testing"
    30  	"time"
    31  
    32  	"github.com/golang/protobuf/jsonpb"
    33  	"github.com/julienschmidt/httprouter"
    34  	"google.golang.org/protobuf/types/known/timestamppb"
    35  
    36  	"go.chromium.org/luci/auth/identity"
    37  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    38  	"go.chromium.org/luci/common/clock/testclock"
    39  	"go.chromium.org/luci/gae/impl/memory"
    40  	"go.chromium.org/luci/server/auth"
    41  	"go.chromium.org/luci/server/auth/authtest"
    42  	"go.chromium.org/luci/server/router"
    43  	"go.chromium.org/luci/server/settings"
    44  	"go.chromium.org/luci/server/templates"
    45  
    46  	"go.chromium.org/luci/milo/frontend/ui"
    47  	"go.chromium.org/luci/milo/internal/model"
    48  	"go.chromium.org/luci/milo/internal/model/milostatus"
    49  	"go.chromium.org/luci/milo/internal/projectconfig"
    50  
    51  	. "github.com/smartystreets/goconvey/convey"
    52  )
    53  
    54  var now = time.Date(2019, time.February, 3, 4, 5, 6, 7, time.UTC)
    55  var nowTS = timestamppb.New(now)
    56  
    57  // TODO(nodir): refactor this file.
    58  
    59  // TestBundle is a template arg associated with a description used for testing.
    60  type TestBundle struct {
    61  	// Description is a short one line description of what the data contains.
    62  	Description string
    63  	// Data is the data fed directly into the template.
    64  	Data templates.Args
    65  }
    66  
    67  type testPackage struct {
    68  	Data         func() []TestBundle
    69  	DisplayName  string
    70  	TemplateName string
    71  }
    72  
    73  var (
    74  	allPackages = []testPackage{
    75  		{buildbucketBuildTestData, "buildbucket.build", "pages/build.html"},
    76  		{consoleTestData, "console", "pages/console.html"},
    77  		{Frontpage, "frontpage", "pages/frontpage.html"},
    78  		{relatedBuildsTableTestData, "widget", "widgets/related_builds_table.html"},
    79  	}
    80  )
    81  
    82  var generate = flag.Bool(
    83  	"test.generate", false, "Generate expectations instead of running tests.")
    84  
    85  func expectFileName(name string) string {
    86  	name = strings.Replace(name, " ", "_", -1)
    87  	name = strings.Replace(name, "/", "_", -1)
    88  	name = strings.Replace(name, ":", "-", -1)
    89  	return filepath.Join("expectations", name)
    90  }
    91  
    92  func load(name string) ([]byte, error) {
    93  	filename := expectFileName(name)
    94  	return os.ReadFile(filename)
    95  }
    96  
    97  // mustWrite Writes a buffer into an expectation file.  Should always work or
    98  // panic.  This is fine because this only runs when -generate is passed in,
    99  // not during tests.
   100  func mustWrite(name string, buf []byte) {
   101  	filename := expectFileName(name)
   102  	err := os.WriteFile(filename, buf, 0644)
   103  	if err != nil {
   104  		panic(err)
   105  	}
   106  }
   107  
   108  func TestPages(t *testing.T) {
   109  	fixZeroDurationRE := regexp.MustCompile(`(Running for:|waiting) 0s?`)
   110  	fixZeroDuration := func(text string) string {
   111  		return fixZeroDurationRE.ReplaceAllLiteralString(text, "[ZERO DURATION]")
   112  	}
   113  
   114  	SkipConvey("Testing basic rendering.", t, func() {
   115  		r := &http.Request{URL: &url.URL{Path: "/foobar"}}
   116  		c := context.Background()
   117  		c = memory.Use(c)
   118  		c, _ = testclock.UseTime(c, now)
   119  		c = auth.WithState(c, &authtest.FakeState{Identity: identity.AnonymousIdentity})
   120  		c = settings.Use(c, settings.New(&settings.MemoryStorage{Expiration: time.Second}))
   121  		c = templates.Use(c, getTemplateBundle("appengine/templates", "testVersionID", false), &templates.Extra{Request: r})
   122  		for _, p := range allPackages {
   123  			Convey(fmt.Sprintf("Testing handler %q", p.DisplayName), func() {
   124  				for _, b := range p.Data() {
   125  					Convey(fmt.Sprintf("Testing: %q", b.Description), func() {
   126  						args := b.Data
   127  						// This is not a path, but a file key, should always be "/".
   128  						tmplName := p.TemplateName
   129  						buf, err := templates.Render(c, tmplName, args)
   130  						So(err, ShouldBeNil)
   131  						fname := fmt.Sprintf(
   132  							"%s-%s.html", p.DisplayName, b.Description)
   133  						if *generate {
   134  							mustWrite(fname, buf)
   135  						} else {
   136  							localBuf, err := load(fname)
   137  							So(err, ShouldBeNil)
   138  							So(fixZeroDuration(string(buf)), ShouldEqual, fixZeroDuration(string(localBuf)))
   139  						}
   140  					})
   141  				}
   142  			})
   143  		}
   144  	})
   145  }
   146  
   147  // buildbucketBuildTestData returns sample test data for build pages.
   148  func buildbucketBuildTestData() []TestBundle {
   149  	bundles := []TestBundle{}
   150  	for _, tc := range []string{"linux-rel", "MacTests", "scheduled"} {
   151  		build, err := GetTestBuild("../buildsource/buildbucket", tc)
   152  		if err != nil {
   153  			panic(fmt.Errorf("Encountered error while fetching %s.\n%s", tc, err))
   154  		}
   155  		bundles = append(bundles, TestBundle{
   156  			Description: fmt.Sprintf("Test page: %s", tc),
   157  			Data: templates.Args{
   158  				"BuildPage": &ui.BuildPage{
   159  					Build: ui.Build{
   160  						Build: build,
   161  						Now:   nowTS,
   162  					},
   163  					BuildbucketHost: "example.com",
   164  				},
   165  				"XsrfTokenField": template.HTML(`<input name="[XSRF Token]" type="hidden" value="[XSRF Token]">`),
   166  				"RetryRequestID": "[Retry Request ID]",
   167  			},
   168  		})
   169  	}
   170  	return bundles
   171  }
   172  
   173  func consoleTestData() []TestBundle {
   174  	builder := &ui.BuilderRef{
   175  		ID:        "buildbucket/luci.project-foo.try/builder-bar",
   176  		ShortName: "tst",
   177  		Build: []*model.BuildSummary{
   178  			{
   179  				Summary: model.Summary{
   180  					Status: milostatus.Success,
   181  				},
   182  			},
   183  			nil,
   184  		},
   185  		Builder: &model.BuilderSummary{
   186  			BuilderID:          "buildbucket/luci.project-foo.try/builder-bar",
   187  			ProjectID:          "project-foo",
   188  			LastFinishedStatus: milostatus.InfraFailure,
   189  		},
   190  	}
   191  	root := ui.NewCategory("Root")
   192  	root.AddBuilder([]string{"cat1", "cat2"}, builder)
   193  	return []TestBundle{
   194  		{
   195  			Description: "Full console with Header",
   196  			Data: templates.Args{
   197  				"Expand": false,
   198  				"Console": consoleRenderer{&ui.Console{
   199  					Name:    "Test",
   200  					Project: "Testing",
   201  					Header: &ui.ConsoleHeader{
   202  						Oncalls: []*ui.OncallSummary{
   203  							{
   204  								Name:      "Sheriff",
   205  								Oncallers: template.HTML("test (primary), watcher (secondary)"),
   206  							},
   207  						},
   208  						Links: []ui.LinkGroup{
   209  							{
   210  								Name: ui.NewLink("Some group", "", ""),
   211  								Links: []*ui.Link{
   212  									ui.NewLink("LiNk", "something", ""),
   213  									ui.NewLink("LiNk2", "something2", ""),
   214  								},
   215  							},
   216  						},
   217  						ConsoleGroups: []ui.ConsoleGroup{
   218  							{
   219  								Title: ui.NewLink("bah", "something2", ""),
   220  								Consoles: []*ui.BuilderSummaryGroup{
   221  									{
   222  										Name: ui.NewLink("hurrah", "something2", ""),
   223  										Builders: []*model.BuilderSummary{
   224  											{
   225  												LastFinishedStatus: milostatus.Success,
   226  											},
   227  											{
   228  												LastFinishedStatus: milostatus.Success,
   229  											},
   230  											{
   231  												LastFinishedStatus: milostatus.Failure,
   232  											},
   233  										},
   234  									},
   235  								},
   236  							},
   237  							{
   238  								Consoles: []*ui.BuilderSummaryGroup{
   239  									{
   240  										Name: ui.NewLink("hurrah", "something2", ""),
   241  										Builders: []*model.BuilderSummary{
   242  											{
   243  												LastFinishedStatus: milostatus.Success,
   244  											},
   245  										},
   246  									},
   247  								},
   248  							},
   249  						},
   250  					},
   251  					Commit: []ui.Commit{
   252  						{
   253  							AuthorEmail: "x@example.com",
   254  							CommitTime:  time.Date(12, 12, 12, 12, 12, 12, 0, time.UTC),
   255  							Revision:    ui.NewLink("12031802913871659324", "blah blah blah", ""),
   256  							Description: "Me too.",
   257  						},
   258  						{
   259  							AuthorEmail: "y@example.com",
   260  							CommitTime:  time.Date(12, 12, 12, 12, 12, 11, 0, time.UTC),
   261  							Revision:    ui.NewLink("120931820931802913", "blah blah blah 1", ""),
   262  							Description: "I did something.",
   263  						},
   264  					},
   265  					Table:    *root,
   266  					MaxDepth: 3,
   267  				}},
   268  			},
   269  		},
   270  	}
   271  }
   272  
   273  func Frontpage() []TestBundle {
   274  	return []TestBundle{
   275  		{
   276  			Description: "Basic frontpage",
   277  			Data: templates.Args{
   278  				"frontpage": ui.Frontpage{
   279  					Projects: []*projectconfig.Project{
   280  						{
   281  							ID:        "fakeproject",
   282  							HasConfig: true,
   283  							LogoURL:   "https://example.com/logo.png",
   284  						},
   285  					},
   286  				},
   287  			},
   288  		},
   289  	}
   290  }
   291  
   292  func relatedBuildsTableTestData() []TestBundle {
   293  	bundles := []TestBundle{}
   294  	for _, tc := range []string{"MacTests", "scheduled"} {
   295  		build, err := GetTestBuild("../buildsource/buildbucket", tc)
   296  		if err != nil {
   297  			panic(fmt.Errorf("Encountered error while fetching %s.\n%s", tc, err))
   298  		}
   299  		bundles = append(bundles, TestBundle{
   300  			Description: fmt.Sprintf("Test related builds table: %s", tc),
   301  			Data: templates.Args{
   302  				"RelatedBuildsTable": &ui.RelatedBuildsTable{
   303  					Build: ui.Build{
   304  						Build: build,
   305  						Now:   nowTS,
   306  					},
   307  					RelatedBuilds: []*ui.Build{{
   308  						Build: build,
   309  						Now:   nowTS,
   310  					}},
   311  				},
   312  			},
   313  		})
   314  	}
   315  	return bundles
   316  }
   317  
   318  // GetTestBuild returns a debug build from testdata.
   319  func GetTestBuild(relDir, name string) (*buildbucketpb.Build, error) {
   320  	fname := fmt.Sprintf("%s.build.jsonpb", name)
   321  	path := filepath.Join(relDir, "testdata", fname)
   322  	f, err := os.Open(path)
   323  	if err != nil {
   324  		return nil, err
   325  	}
   326  	defer f.Close()
   327  	result := &buildbucketpb.Build{}
   328  	return result, jsonpb.Unmarshal(f, result)
   329  }
   330  
   331  func TestCreateInterpolator(t *testing.T) {
   332  	Convey("Test createInterpolator", t, func() {
   333  		Convey("Should encode params", func() {
   334  			params := httprouter.Params{httprouter.Param{Key: "component2", Value: ":? +"}}
   335  			interpolator := createInterpolator("/component1/:component2")
   336  
   337  			path := interpolator(params)
   338  			So(path, ShouldEqual, "/component1/"+url.PathEscape(":? +"))
   339  		})
   340  
   341  		Convey("Should support catching path segments with *", func() {
   342  			params := httprouter.Params{httprouter.Param{Key: "component2", Value: "/:?/ +"}}
   343  			interpolator := createInterpolator("/component1/*component2")
   344  
   345  			path := interpolator(params)
   346  			So(path, ShouldEqual, "/component1/"+url.PathEscape(":?")+"/"+url.PathEscape(" +"))
   347  		})
   348  
   349  		Convey("Should support encoding / with *_", func() {
   350  			params := httprouter.Params{httprouter.Param{Key: "_component2", Value: "/:?/ +"}}
   351  			interpolator := createInterpolator("/component1/*_component2")
   352  
   353  			path := interpolator(params)
   354  			So(path, ShouldEqual, "/component1/"+url.PathEscape(":?/ +"))
   355  		})
   356  	})
   357  }
   358  
   359  func TestRedirect(t *testing.T) {
   360  	Convey("Test redirect", t, func() {
   361  		client := &http.Client{
   362  			// Don't follow the redirect. We want to test the response directly.
   363  			CheckRedirect: func(req *http.Request, via []*http.Request) error {
   364  				return http.ErrUseLastResponse
   365  			},
   366  		}
   367  
   368  		r := router.New()
   369  		ts := httptest.NewServer(r)
   370  
   371  		Convey("Should not double-encode params", func() {
   372  			r.GET("/foo/:param", router.NewMiddlewareChain(), redirect("/bar/:param", http.StatusFound))
   373  			res, err := client.Get(ts.URL + "/foo/" + url.PathEscape(":? "))
   374  			So(err, ShouldBeNil)
   375  			So(res.StatusCode, ShouldEqual, http.StatusFound)
   376  			So(res.Header.Get("Location"), ShouldEqual, "/bar/"+url.PathEscape(":? "))
   377  		})
   378  	})
   379  }