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"