diff --git a/internal/api/client.go b/internal/api/client.go index 7f642a1..65a0386 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -10,6 +11,15 @@ import ( const BaseURL = "https://cloud-api.nan.builders/api" +// ErrSessionExpired is the platform refusing the session a request carried. +// The session token expires on the server, and nothing on this side knows +// when: session.json still holds it, so everything reading that file goes on +// believing the member is signed in. What the platform says instead is +// `invalid session`, which names what it decided and not what to do about it, +// so a 401 comes back as this and the caller can tell it apart from a +// platform that is down. +var ErrSessionExpired = errors.New("your session has expired") + // A client with no timeout waits forever on a connection that is accepted and // then never answered, which in the panel is a spinner that never stops. const requestTimeout = 30 * time.Second @@ -54,6 +64,9 @@ func (c *Client) get(path string) ([]byte, error) { if err != nil { return nil, err } + if resp.StatusCode == http.StatusUnauthorized && c.token != "" { + return nil, ErrSessionExpired + } if resp.StatusCode >= 400 { var e struct { Error string `json:"error"` diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 16b9f35..b75cda8 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "net/http/httptest" "strings" @@ -54,3 +55,34 @@ func TestKeyStatusSaysWhenThereIsNoKey(t *testing.T) { t.Error("an account with no key reads as having one") } } + +// The platform answers a session it no longer honours with 401 and +// `invalid session`, and the panel has to tell that apart from a platform that +// is down: one means sign in again, the other means try later. +func TestAnExpiredSessionIsNamedAsOne(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid session"}`)) + })) + defer srv.Close() + + c := &Client{token: "stale", http: srv.Client(), baseURL: srv.URL} + if _, err := c.GetMe(); !errors.Is(err, ErrSessionExpired) { + t.Errorf("a refused session comes back as %v", err) + } + + // With no token there was no session to expire, and saying so would send + // someone who never signed in to sign in "again". + c.token = "" + if _, err := c.GetMe(); errors.Is(err, ErrSessionExpired) { + t.Error("a request with no session reads as an expired one") + } + + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }) + c.token = "stale" + if _, err := c.GetMe(); errors.Is(err, ErrSessionExpired) { + t.Error("a platform that is down reads as an expired session") + } +} diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go new file mode 100644 index 0000000..86ec1f7 --- /dev/null +++ b/internal/tui/session_test.go @@ -0,0 +1,115 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/session" +) + +// A session the platform stopped honouring left its token in session.json, so +// the panel went on believing it: Home said the member was signed in, `s` did +// nothing because there was a session, and every other tab said `invalid +// session`. The one key that fixes it was the one switched off. +func TestAnExpiredSessionCanBeSignedBackInto(t *testing.T) { + sess := &session.Session{Token: "stale", APIKey: "sk-still-good"} + m := setupModel(t, sess) + m.client = api.New("stale") + m.lay = newLayout(100, 40) + m.active = tabIndex(tabProfile) + + mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) + m = mod.(model) + + if m.sess.Token != "" { + t.Error("the panel still holds the session the platform refused") + } + if m.sess.APIKey != "sk-still-good" { + t.Error("the API key went with the session, and it had not expired") + } + if m.client.Token() != "" { + t.Error("the client still sends the refused session") + } + if !strings.Contains(m.View(), "press s") { + t.Errorf("the tab does not say what to do:\n%s", m.View()) + } + + // On disk too, or the next start is the same panel again. + saved, err := session.Load() + if err != nil { + t.Fatal(err) + } + if saved.Token != "" || saved.APIKey != "sk-still-good" { + t.Errorf("session.json kept %+v", saved) + } + + // Back to Home, which has to offer the sign-in again - and not the error + // the tab it came from was showing. + mod, _ = m.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + m = mod.(model) + if m.activeID() != tabHome { + t.Fatalf("shift+tab from Profile lands on %v", m.activeID()) + } + home := m.View() + if strings.Contains(home, errSessionExpired.Error()) { + t.Errorf("Home is drawing the error from the tab before it:\n%s", home) + } + if !strings.Contains(home, "sign in") { + t.Errorf("Home does not offer the sign-in:\n%s", home) + } + + mod, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + if mod.(model).loginStage != loginAskEmail { + t.Error("s still does nothing after the session expired") + } +} + +// A refusal of the old session that arrives after the member has signed in +// again is about a session that is already gone, and must not take the new +// one with it. +func TestALateRefusalDoesNotSignOutTheNewSession(t *testing.T) { + m := setupModel(t, &session.Session{Token: "fresh"}) + m.client = api.New("fresh") + + mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) + if got := mod.(model).sess.Token; got != "fresh" { + t.Errorf("the new session was dropped for the old one's refusal (token %q)", got) + } +} + +// Opening the panel onto an expired session is the same moment as opening it +// with none, so it goes straight to the sign-in the way a fresh machine does. +func TestOpeningOnAnExpiredSessionAsksToSignIn(t *testing.T) { + m := setupModel(t, &session.Session{Token: "stale", APIKey: "sk"}) + m.client = api.New("stale") + + mod, cmd := m.Update(firstRunMsg{}) + m = mod.(model) + if cmd == nil { + t.Fatal("the panel opens on a session without asking whether it still works") + } + if m.loginStage != loginOff { + t.Fatal("the panel asks for a sign-in before it knows the session is gone") + } + + mod, _ = m.Update(sessionExpiredMsg{token: "stale", atStart: true}) + if mod.(model).loginStage != loginAskEmail { + t.Error("opening on an expired session does not start the sign-in") + } +} + +// The same answer later on, while someone is reading a tab, says so and waits: +// the sign-in takes every key while it is up. +func TestASessionThatExpiresLaterDoesNotGrabTheKeyboard(t *testing.T) { + m := setupModel(t, &session.Session{Token: "stale"}) + m.client = api.New("stale") + m.active = tabIndex(tabUsage) + + mod, _ := m.Update(sessionExpiredMsg{token: "stale"}) + if mod.(model).loginStage != loginOff { + t.Error("an expiry mid-session threw the member into the sign-in") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6212f99..f1abf47 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -129,6 +129,20 @@ type keyCheckedMsg struct { // someone to quit and run something is the detour this replaced. var errNotSignedIn = errors.New("not signed in — press s") +// What a tab says when the platform turned the saved session down. The token +// was still in session.json, so until the first request the panel had every +// reason to think the member was signed in. +var errSessionExpired = errors.New("your session has expired — press s to sign in again") + +// The platform refused the session the panel was holding. token is the one +// the request carried, so an answer that was already on its way before a new +// sign-in cannot throw the new session away. atStart is the check the panel +// makes when it opens. +type sessionExpiredMsg struct { + token string + atStart bool +} + type configuredMsg struct { msg string written []string @@ -319,6 +333,35 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false m.err = msg.err + case sessionExpiredMsg: + if msg.token == "" || msg.token != m.sess.Token { + return m, nil + } + // An expired session is no session. Leaving the token in place kept + // Home saying the member was signed in, kept `s` doing nothing, and + // answered every tab `invalid session` - with the one key that fixes + // it switched off. The key and the tools stay: they are the member's + // and still work, and the session was the only thing that went stale. + m.sess.Token = "" + _ = session.Save(m.sess) + m.client = api.New("") + m.cache = make(map[tabID]any) + m.keyStatus, m.keyAsked = nil, false + m.loading = false + m.err = errSessionExpired + // Opening the panel on an expired session is the same moment as + // opening it with none, so it gets the same sign-in. Anywhere later it + // only says so: the prompt takes every key while it is up, and a + // member reading their usage did not ask for it. The key field the + // panel may have opened meanwhile gives way, as it would have with no + // session at all - unless something has already been typed into it. + if msg.atStart && m.wizard == wizardOff && m.loginStage == loginOff && m.keyInput.Value() == "" { + m.editingKey = false + m.keyInput.Blur() + m.err = nil + return m, m.startLogin() + } + case configuredMsg: m.configuring = false m.setupMsg = msg.msg @@ -333,7 +376,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case firstRunMsg: - return m, m.resumeSetup() + return m, tea.Batch(m.resumeSetup(), m.checkSession()) case linkSentMsg: m.loginBusy = false @@ -643,6 +686,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *model) maybeLoad() tea.Cmd { id := m.activeID() + // The error belongs to the tab that asked. There is one m.err for all of + // them, and the tabs that load nothing - Home, About, one already in the + // cache - never cleared it, so walking back to Home after a tab failed + // drew that tab's error where Home should be. + m.err = nil // Setup asks the platform one thing, once, and stays usable while it // waits: no spinner, no error state, nothing that stops a member pasting // a key on a train. @@ -779,6 +827,9 @@ func (m model) fetchKeyStatus() tea.Cmd { client := m.client return func() tea.Msg { status, err := client.GetKeyStatus() + if errors.Is(err, api.ErrSessionExpired) { + return sessionExpiredMsg{token: client.Token()} + } if err != nil { // Silent on purpose: this is a hint, and a member with no // connection still has a Setup tab that works. @@ -822,7 +873,42 @@ func (m model) needsLogin(id tabID) bool { return id != tabHome && id != tabAbout && id != tabSetup } +// checkSession asks the platform whether the saved session still works, once, +// when the panel opens. Home draws from whether there is a token, and the +// token outlives the session on the server, so without this a member whose +// session had expired opened the panel onto a Home that said they were signed +// in. Anything but a refusal says nothing: offline is not signed out. +func (m model) checkSession() tea.Cmd { + client := m.client + if client == nil || m.sess.Token == "" { + return nil + } + return func() tea.Msg { + if _, err := client.GetMe(); errors.Is(err, api.ErrSessionExpired) { + return sessionExpiredMsg{token: client.Token(), atStart: true} + } + return nil + } +} + +// fetchTab is fetchTabData with the one answer every tab shares taken out of +// it: a refused session is not that tab's error, it is the panel's. func (m model) fetchTab(id tabID) tea.Cmd { + fetch := m.fetchTabData(id) + var token string + if m.client != nil { + token = m.client.Token() + } + return func() tea.Msg { + msg := fetch() + if e, ok := msg.(fetchErrMsg); ok && errors.Is(e.err, api.ErrSessionExpired) { + return sessionExpiredMsg{token: token} + } + return msg + } +} + +func (m model) fetchTabData(id tabID) tea.Cmd { client := m.client apiKey := m.sess.APIKey needsLogin := m.needsLogin(id)