364 lines
11 KiB
Go
364 lines
11 KiB
Go
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: "<html>403 Forbidden</html>",
|
|
wantCode: api.CodeForbidden,
|
|
wantMsg: "<html>403 Forbidden</html>",
|
|
},
|
|
{
|
|
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)
|
|
}
|
|
}
|