From 70c447873d31ab61009b3bd94561bf3e1a98dae7 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 11 Sep 2026 15:31:55 +0530 Subject: [PATCH] "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 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- desktop/internal/cloud/client.go | 9 +++++- desktop/internal/cloud/client_test.go | 41 ++++++++++++++++++++++++++ installer/run-with-lan-head-office.cmd | 21 +++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 installer/run-with-lan-head-office.cmd diff --git a/desktop/internal/cloud/client.go b/desktop/internal/cloud/client.go index d2591c6..9d575e7 100644 --- a/desktop/internal/cloud/client.go +++ b/desktop/internal/cloud/client.go @@ -180,9 +180,16 @@ func (c *Client) send(ctx context.Context, method, path string, raw []byte, out switch { case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired": return errTokenExpired - case resp.StatusCode == http.StatusUnauthorized: + case resp.StatusCode == http.StatusUnauthorized && tok != "": + // A 401 on a call we sent a session with: the session is the problem. return ErrUnauthorized case resp.StatusCode >= 400: + // Every other 4xx/5xx - including a 401 on a call that carried NO + // session, such as redeeming an installation code - is about the + // request, and the server wrote its message for exactly this moment. + // Mapping those to "session expired" told an installer their session + // had lapsed on a screen where they had never signed in, and hid + // "That installation code is not valid" behind it. msg := e.Message if msg == "" { msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status) diff --git a/desktop/internal/cloud/client_test.go b/desktop/internal/cloud/client_test.go index cd90df6..c657240 100644 --- a/desktop/internal/cloud/client_test.go +++ b/desktop/internal/cloud/client_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -137,3 +138,43 @@ func TestVisitorIDIsPathEscaped(t *testing.T) { 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) + } +} diff --git a/installer/run-with-lan-head-office.cmd b/installer/run-with-lan-head-office.cmd new file mode 100644 index 0000000..d5c14cd --- /dev/null +++ b/installer/run-with-lan-head-office.cmd @@ -0,0 +1,21 @@ +@echo off +rem Start Behavision against a head office running on another PC on this LAN, +rem instead of the production server it uses by default. +rem +rem For demos and pilots only. Two things are deliberately weaker than +rem production and both are named here so nobody copies this into a shop: +rem +rem - head office over plain http, not https +rem - the message broker over plain tcp. The app REFUSES plaintext MQTT to +rem any address that is not its own machine, by design - the payloads are +rem customer visit records - so the second line below is the documented +rem escape hatch and must not be set anywhere that is not a demo. +rem +rem Edit the address to the PC running head office, then double-click this +rem instead of Behavision.exe. Everything else - the installation code, the +rem sign-in, the cameras - works exactly as INSTALL.txt describes. + +set BEHAVISION_CLOUD=http://192.168.1.117:8088 +set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 + +start "" "%~dp0Behavision.exe"