Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
32 changes: 32 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"errors"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -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")
}
}
115 changes: 115 additions & 0 deletions internal/tui/session_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
88 changes: 87 additions & 1 deletion internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading