The first Windows install reached the setup screen, typed an installation code, and was told the session had expired. There was no session. The code had been minted on a different head office, and the server said so - 401 bad_token, "That installation code is not valid. Ask for a new one." - and the client threw the message away, because it mapped every 401 to the string "session expired". A 401 on a call that carried a session is a session problem. A 401 on a call that carried none is about the request, and the server's message is the answer. The client now tells them apart by whether it sent a token. Two tests, one for each side of the rule. Also: a launcher for pointing a Windows PC at a head office on the LAN, with the two settings that needs and a comment saying why neither is acceptable outside a demo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
181 lines
6.2 KiB
Go
181 lines
6.2 KiB
Go
package cloud
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func serve(t *testing.T, h http.HandlerFunc) *Client {
|
|
t.Helper()
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
c := New(srv.URL)
|
|
c.SetSession(Session{Token: "test-token"})
|
|
return c
|
|
}
|
|
|
|
func fail(w http.ResponseWriter, status int, code, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
|
|
}
|
|
|
|
// A customer with no photo is the DEFAULT configuration of this product, not a
|
|
// fault. If it surfaced as an error the record sheet would show a red failure
|
|
// box for every customer in every shop that has not turned images on.
|
|
func TestNoPhotoIsNotAnError(t *testing.T) {
|
|
for _, tc := range []struct{ code, want string }{
|
|
{"no_image", "No photo"},
|
|
{"images_disabled", "not storing"},
|
|
} {
|
|
t.Run(tc.code, func(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
fail(w, http.StatusNotFound, tc.code, "server prose")
|
|
})
|
|
p, err := c.VisitorImage(context.Background(), "abc")
|
|
if err != nil {
|
|
t.Fatalf("returned an error for a normal state: %v", err)
|
|
}
|
|
if p.Available {
|
|
t.Error("Available should be false when there is no photo")
|
|
}
|
|
if p.Reason == "" {
|
|
t.Error("a missing photo must come with an explanation")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPhotoReturnsTheSignedLink(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
|
t.Errorf("Authorization = %q", got)
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"url": "https://example.test/signed", "expires_in": 900})
|
|
})
|
|
p, err := c.VisitorImage(context.Background(), "abc")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 {
|
|
t.Fatalf("got %+v", p)
|
|
}
|
|
}
|
|
|
|
// A real failure must still be a failure: silently rendering initials would
|
|
// hide a broken server behind a design that looks intentional.
|
|
func TestPhotoServerErrorIsAnError(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
fail(w, http.StatusInternalServerError, "server_error", "boom")
|
|
})
|
|
if _, err := c.VisitorImage(context.Background(), "abc"); err == nil {
|
|
t.Fatal("a 500 must not be reported as 'no photo'")
|
|
}
|
|
}
|
|
|
|
// The server deletes stored images before it touches the database and refuses
|
|
// the whole request if one fails, so an error here means NOTHING was erased.
|
|
// Swallowing it would tell a shop a legal request had been honoured when it
|
|
// had not.
|
|
func TestForgetVisitorSurfacesFailure(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
t.Errorf("method = %s, want DELETE", r.Method)
|
|
}
|
|
fail(w, http.StatusBadGateway, "storage_error",
|
|
"The photo could not be deleted, so nothing was erased.")
|
|
})
|
|
err := c.ForgetVisitor(context.Background(), "abc")
|
|
if err == nil {
|
|
t.Fatal("a refused erasure must not look like success")
|
|
}
|
|
if err.Error() != "The photo could not be deleted, so nothing was erased." {
|
|
t.Errorf("lost the server's own words: %q", err)
|
|
}
|
|
}
|
|
|
|
func TestForgetVisitorSucceedsOn204(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
if err := c.ForgetVisitor(context.Background(), "abc"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// APIError carries the code without changing what anything that prints the
|
|
// error sees — every existing screen relies on that text.
|
|
func TestAPIErrorKeepsServerMessage(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
fail(w, http.StatusForbidden, "forbidden",
|
|
"Your account cannot delete customer records.")
|
|
})
|
|
err := c.ForgetVisitor(context.Background(), "abc")
|
|
if err.Error() != "Your account cannot delete customer records." {
|
|
t.Errorf("message = %q", err)
|
|
}
|
|
var ae *APIError
|
|
if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 {
|
|
t.Errorf("code not preserved: %+v", ae)
|
|
}
|
|
}
|
|
|
|
// An id with a slash or a space must not silently address a different route.
|
|
func TestVisitorIDIsPathEscaped(t *testing.T) {
|
|
var got string
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.URL.EscapedPath()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck
|
|
if got != "/api/visitors/a%20b%2Fc" {
|
|
t.Errorf("path = %q", got)
|
|
}
|
|
}
|
|
|
|
// Redeeming an installation code is the one call a fresh PC makes before it
|
|
// has any session. When the server refuses it - wrong code, wrong head office -
|
|
// it answers 401 with a message written for the installer. That message must
|
|
// reach them: "session expired" on a screen where nobody has signed in sent a
|
|
// real installer looking for a login problem that did not exist.
|
|
func TestARefusedInstallationCodeSaysWhyNotSessionExpired(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "" {
|
|
t.Errorf("enrol must not carry a session, got %q", r.Header.Get("Authorization"))
|
|
}
|
|
fail(w, http.StatusUnauthorized, "bad_token",
|
|
"That installation code is not valid. Ask for a new one.")
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
c := New(srv.URL) // deliberately no session
|
|
|
|
_, err := c.Bootstrap(context.Background(), "KWFH5S-EH46LT-EE4X47-OSOH7D")
|
|
if err == nil {
|
|
t.Fatal("a refused code must be an error")
|
|
}
|
|
if errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("a refused code is not a session problem, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "installation code is not valid") {
|
|
t.Fatalf("the server's own words should reach the installer, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The other side of the same rule: a 401 on a call that DID carry a session is
|
|
// a session problem, and must still read as one.
|
|
func TestARejectedSessionStillReadsAsSessionExpired(t *testing.T) {
|
|
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
|
|
fail(w, http.StatusUnauthorized, "unauthorized", "Sign in again.")
|
|
})
|
|
err := c.do(context.Background(), http.MethodGet, "/api/auth/me", nil, nil)
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("a 401 with a session should be ErrUnauthorized, got %v", err)
|
|
}
|
|
}
|