package client import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/iceBear67/simplepages/api" ) const testToken = "pgs_abcdefghijklmnop_ThisIsTheSecretHalfAndMustNotLeakAnywhere" func TestNewValidatesConfig(t *testing.T) { cases := []struct { desc string cfg Config wantErr string }{ {"no server", Config{Token: testToken}, "no server URL"}, {"no token", Config{BaseURL: "https://p.example.com"}, "no token"}, {"missing scheme", Config{BaseURL: "p.example.com", Token: testToken}, "missing scheme"}, {"wrong scheme", Config{BaseURL: "ftp://p.example.com", Token: testToken}, "scheme must be http or https"}, {"missing host", Config{BaseURL: "https://", Token: testToken}, "missing host"}, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { _, err := New(tc.cfg) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("err = %v, want one containing %q", err, tc.wantErr) } // A rejected URL is echoed back so the operator can see the typo, but // the token must never turn up in a message they might paste anywhere. if err != nil && strings.Contains(err.Error(), testToken) { t.Error("error message contains the token") } }) } } func TestNewTrimsTrailingSlash(t *testing.T) { c, err := New(Config{BaseURL: "https://p.example.com/", Token: testToken}) if err != nil { t.Fatal(err) } if got := c.BaseURL(); got != "https://p.example.com" { t.Errorf("BaseURL = %q, want no trailing slash", got) } } // TestRequestCarriesTokenInHeaderOnly is the wire half of the rule that a // token never reaches a proxy access log: it belongs in Authorization, and // nowhere near the URL. func TestRequestCarriesTokenInHeaderOnly(t *testing.T) { var gotAuth, gotURL, gotAccept, gotAgent string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth, gotURL = r.Header.Get("Authorization"), r.URL.String() gotAccept, gotAgent = r.Header.Get("Accept"), r.Header.Get("User-Agent") w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"key_id":"abcdefghijklmnop","scope":"admin"}`)) })) defer srv.Close() c, err := New(Config{BaseURL: srv.URL, Token: testToken}) if err != nil { t.Fatal(err) } who, err := c.WhoAmI(context.Background()) if err != nil { t.Fatal(err) } if who.KeyID != "abcdefghijklmnop" || who.Scope != "admin" { t.Errorf("decoded %+v", who) } if gotAuth != "Bearer "+testToken { t.Errorf("Authorization = %q", gotAuth) } if strings.Contains(gotURL, "pgs_") { t.Errorf("token reached the URL: %q", gotURL) } if gotAccept != "application/json" { t.Errorf("Accept = %q", gotAccept) } if !strings.HasPrefix(gotAgent, "pages/") { t.Errorf("User-Agent = %q", gotAgent) } } // TestRedirectIsRefused: following a redirect would send the bearer token to a // host the operator never named. func TestRedirectIsRefused(t *testing.T) { elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("request followed the redirect and reached %s", r.Host) })) defer elsewhere.Close() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, elsewhere.URL+r.URL.Path, http.StatusFound) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) _, err := c.WhoAmI(context.Background()) if err == nil { t.Fatal("expected an error") } if !strings.Contains(err.Error(), "redirected") { t.Errorf("err = %v, want it to explain the redirect", err) } } func TestErrorResponses(t *testing.T) { cases := []struct { desc string status int body string contentType string wantCode api.Code wantMsg string }{ { desc: "server envelope is used verbatim", status: http.StatusConflict, body: `{"error":{"code":"project_exists","message":"project demo already exists"}}`, wantCode: "project_exists", wantMsg: "project demo already exists", }, { desc: "a bare proxy error still gets a code from the status", status: http.StatusForbidden, body: "403 Forbidden", wantCode: api.CodeForbidden, wantMsg: "403 Forbidden", }, { desc: "an empty body falls back to the status text", status: http.StatusBadGateway, body: "", wantCode: api.CodeInternal, wantMsg: "Bad Gateway", }, { desc: "an envelope without a code is not an envelope", status: http.StatusNotFound, body: `{"error":{"message":"nope"}}`, wantCode: api.CodeNotFound, wantMsg: `{"error":{"message":"nope"}}`, }, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status) w.Write([]byte(tc.body)) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) _, err := c.WhoAmI(context.Background()) var apiErr *api.Error if !errors.As(err, &apiErr) { t.Fatalf("err = %v (%T), want *api.Error", err, err) } if apiErr.Code != tc.wantCode { t.Errorf("code = %q, want %q", apiErr.Code, tc.wantCode) } if apiErr.Message != tc.wantMsg { t.Errorf("message = %q, want %q", apiErr.Message, tc.wantMsg) } }) } } // TestHugeErrorBodyIsTruncated: a wrong --server can point at something that // answers every request with a megabyte of HTML. func TestHugeErrorBodyIsTruncated(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(strings.Repeat("x", 1<<20))) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) _, err := c.WhoAmI(context.Background()) if err == nil { t.Fatal("expected an error") } if len(err.Error()) > 400 { t.Errorf("error message is %d bytes; it should be truncated", len(err.Error())) } } func TestNoContentNeedsNoBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("method = %s", r.Method) } w.WriteHeader(http.StatusNoContent) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) if err := c.DeleteProject(context.Background(), "demo"); err != nil { t.Fatalf("DeleteProject: %v", err) } } // TestTimeoutSaysWhichFlagToRaise — the timeout is the one failure a user can // fix from the message alone, so the message has to name the flag. func TestTimeoutSaysWhichFlagToRaise(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release })) defer func() { close(release); srv.Close() }() c, _ := New(Config{BaseURL: srv.URL, Token: testToken, Timeout: 50 * time.Millisecond}) _, err := c.WhoAmI(context.Background()) if err == nil { t.Fatal("expected a timeout") } if !strings.Contains(err.Error(), "--timeout") { t.Errorf("err = %v, want it to mention --timeout", err) } } // TestDeploymentQueriesTravelInTheURL. State, limit and cursor are the three // knobs of a deployment listing and all three ride the query string; one that // went missing would leave a listing that quietly answers a different question. func TestDeploymentQueriesTravelInTheURL(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.URL.RequestURI() w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"deployments":[]}`)) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) ctx := context.Background() list := api.PathDeployments("demo") one := api.PathDeployment("demo", "dpl_1") cases := []struct { desc string call func() error want string }{ { "no options, no query", func() error { _, err := c.ListDeployments(ctx, "demo", DeploymentListOptions{}); return err }, list, }, { "every option set", func() error { opts := DeploymentListOptions{State: "ready"} opts.Limit, opts.Cursor = 50, "dpl_9" _, err := c.ListDeployments(ctx, "demo", opts) return err }, list + "?cursor=dpl_9&limit=50&state=ready", }, { "a manifest is not fetched by default", func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", false); return err }, one, }, { "asking for the manifest", func() error { _, err := c.GetDeployment(ctx, "demo", "dpl_1", true); return err }, one + "?files=true", }, } for _, tc := range cases { t.Run(tc.desc, func(t *testing.T) { if err := tc.call(); err != nil { t.Fatal(err) } if got != tc.want { t.Errorf("requested %q, want %q", got, tc.want) } }) } } // TestUpkeepPostsItsFlag: a --dry-run that silently ran for real, or a fsck // that repaired without being asked, are the two ways these calls can be // dangerous, and both live in the request body. func TestUpkeepPostsItsFlag(t *testing.T) { var gotMethod, gotPath, gotBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) gotMethod, gotPath, gotBody = r.Method, r.URL.Path, string(body) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{}`)) })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) ctx := context.Background() if _, err := c.Collect(ctx, true); err != nil { t.Fatal(err) } if gotMethod != http.MethodPost || gotPath != api.PathGC() { t.Errorf("collect sent %s %s", gotMethod, gotPath) } if !strings.Contains(gotBody, `"dry_run":true`) { t.Errorf("collect body = %q, want dry_run set", gotBody) } if _, err := c.Fsck(ctx, true); err != nil { t.Fatal(err) } if gotMethod != http.MethodPost || gotPath != api.PathFsck() { t.Errorf("fsck sent %s %s", gotMethod, gotPath) } if !strings.Contains(gotBody, `"repair":true`) { t.Errorf("fsck body = %q, want repair set", gotBody) } } // TestListAllProjectsFollowsTheCursor: paging is invisible to the CLI, so the // only place it can break is here. func TestListAllProjectsFollowsTheCursor(t *testing.T) { var seen []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cursor := r.URL.Query().Get("cursor") seen = append(seen, cursor) w.Header().Set("Content-Type", "application/json") switch cursor { case "": w.Write([]byte(`{"projects":[{"name":"a"},{"name":"b"}],"next_cursor":"b"}`)) case "b": w.Write([]byte(`{"projects":[{"name":"c"}]}`)) default: t.Errorf("unexpected cursor %q", cursor) } })) defer srv.Close() c, _ := New(Config{BaseURL: srv.URL, Token: testToken}) got, err := c.ListAllProjects(context.Background()) if err != nil { t.Fatal(err) } var names []string for _, p := range got { names = append(names, p.Name) } if strings.Join(names, ",") != "a,b,c" { t.Errorf("projects = %v, want a,b,c", names) } if len(seen) != 2 || seen[0] != "" || seen[1] != "b" { t.Errorf("cursors requested = %q", seen) } }