Files
pages/internal/client/client_test.go
T
2026-08-15 07:13:00 +00:00

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)
}
}