Files
Behavision/desktop/internal/cloud/client_test.go
Suriyakumarvijayanayagam 70c447873d "Session expired" on a screen where nobody had signed in
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
2026-09-11 15:31:55 +05:30

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