package tui import ( "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/session" "git.ryuvia.com/niklas/terdut-tui/internal/theme" tea "github.com/charmbracelet/bubbletea" ) // signedOut is a model with no session, so it starts on the sign-in form. func signedOut(serverURL string) Model { m := NewModel(api.NewClient(serverURL), serverURL, time.Minute, theme.GruvboxDark) m.width, m.height = 120, 40 return m } func TestStartsOnTheFormWithoutASession(t *testing.T) { m := signedOut("http://test") if m.mode != modeLogin { t.Fatalf("expected the sign-in form, got mode %v", m.mode) } // Nothing is connected without a session. The one thing started is asking // how the server can be signed in to, and a server too old to be asked (404) // must leave the password form as it was. srv := httptest.NewServer(http.NotFoundHandler()) t.Cleanup(srv.Close) cmd := signedOut(srv.URL).Init() if cmd == nil { t.Fatal("expected the form to ask the server how it can be signed in to") } msg, ok := cmd().(authConfigMsg) if !ok { t.Fatalf("expected authConfigMsg, got %#v", cmd()) } if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin { t.Errorf("an old server offers passwords only, got %+v", msg.cfg) } } func TestStartsConnectedWithASavedSession(t *testing.T) { c := api.NewClient("http://test") c.SetSession("saved") m := NewModel(c, "http://test", time.Minute, theme.GruvboxDark) if m.mode == modeLogin { t.Fatal("a saved session should be tried before asking for a password") } if m.Init() == nil { t.Error("expected the saved session to be tried on start") } } func TestLoginForm_ChecksBothFieldsBeforeSending(t *testing.T) { m := signedOut("http://test") m, cmd := press(t, m, "enter") if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "username") { t.Errorf("an empty form must not be sent, got err %q", m.loginErr) } m = typeInto(t, m, "niklas") m, cmd = press(t, m, "enter") if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "password") { t.Errorf("a missing password must not be sent, got err %q", m.loginErr) } } func TestLoginForm_QIsTypedNotQuit(t *testing.T) { m := signedOut("http://test") m = typeInto(t, m, "quentin") if got := m.loginInputs[loginUsername].Value(); got != "quentin" { t.Errorf("q is a letter in a username, got %q", got) } } // The whole flow against a server: type both fields, submit, and the session is // kept for the next run. func TestLogin_SignsInAndSavesTheSession(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) if r.URL.Path != "/api/login" || string(b) != `{"username":"niklas","password":"secret-pass"}` { t.Errorf("unexpected request %s %s", r.URL.Path, b) } http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-1", Path: "/"}) io.WriteString(w, `{}`) })) t.Cleanup(srv.Close) m := signedOut(srv.URL) m = typeInto(t, m, "niklas") m, _ = press(t, m, "tab") m = typeInto(t, m, "secret-pass") m, cmd := press(t, m, "enter") if !m.loggingIn || cmd == nil { t.Fatal("expected the sign-in to be under way") } msg := cmd() if _, ok := msg.(loginDoneMsg); !ok { t.Fatalf("expected loginDoneMsg, got %#v", msg) } if got := session.Load(srv.URL); got != "tok-1" { t.Errorf("expected the session saved for next time, got %q", got) } next, connect := m.Update(msg) m = next.(Model) if m.mode != modeDashboard || m.loggingIn || connect == nil { t.Errorf("expected to move on and connect, got mode %v", m.mode) } if m.loginInputs[loginPassword].Value() != "" { t.Error("the password must not be kept once it has been used") } } func TestLogin_WrongPasswordStaysOnTheForm(t *testing.T) { m := signedOut("http://test") m.loggingIn = true next, _ := m.Update(loginErrMsg{&api.StatusError{Code: 401, Message: "invalid username or password"}}) m = next.(Model) if m.mode != modeLogin || m.loggingIn { t.Fatalf("expected to be back on the form, got mode %v", m.mode) } // The server gives the same 401 for an account with no password, so the // message has to say so or it reads as a typo. if !strings.Contains(m.loginErr, "no password") { t.Errorf("expected the no-password hint, got %q", m.loginErr) } if m.loginFocus != loginPassword { t.Error("focus should return to the password to retry") } next, _ = m.Update(loginErrMsg{&api.StatusError{Code: 429, Message: "slow down"}}) if got := next.(Model).loginErr; !strings.Contains(got, "too many") { t.Errorf("expected the rate limit explained, got %q", got) } next, _ = m.Update(loginErrMsg{errors.New("dial tcp: refused")}) if got := next.(Model).loginErr; !strings.Contains(got, "refused") { t.Errorf("other errors should show as they are, got %q", got) } } // A 401 anywhere means the session is gone. Every action would fail the same // way, so it goes back to the form instead, without keeping the old data. func TestUnauthorized_ReturnsToTheFormAndDropsTheData(t *testing.T) { for name, msg := range map[string]tea.Msg{ "refresh": fetchDataErrMsg{&api.StatusError{Code: 401}}, "action": actionErrMsg{&api.StatusError{Code: 401}}, "schedule": scheduleActionErrMsg{&api.StatusError{Code: 401}}, "connect": connectErrMsg{&api.StatusError{Code: 401}}, } { t.Run(name, func(t *testing.T) { m := sized() m.incidents = []api.Incident{{ID: 1, Title: "secret incident"}} m.teams = twoTeams() m.isAdmin = true m.loginInputs[loginUsername].SetValue("niklas") next, cmd := m.Update(msg) m = next.(Model) if m.mode != modeLogin || m.connected { t.Fatalf("expected the sign-in form, got mode %v connected=%v", m.mode, m.connected) } if len(m.incidents) != 0 || len(m.teams) != 0 || m.isAdmin { t.Error("the previous session's data must not survive into the next sign-in") } if cmd == nil { t.Error("the dead session should be forgotten on disk") } if !strings.Contains(m.loginNote, "session") { t.Errorf("expected the reason, got %q", m.loginNote) } if m.loginInputs[loginUsername].Value() != "niklas" || m.loginFocus != loginPassword { t.Error("the username should be kept so only the password is retyped") } if strings.Contains(m.View(), "secret incident") { t.Error("nothing from the old session may still be on screen") } }) } } // A 403 is a permission, not a lost session: it must not sign anybody out. func TestForbidden_DoesNotSignOut(t *testing.T) { m := sized() next, _ := m.Update(actionErrMsg{&api.StatusError{Code: 403, Message: "administrator access required"}}) if got := next.(Model); got.mode == modeLogin || !got.connected { t.Error("a 403 must leave the session alone") } } func TestLogout(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) m := sized() m.teams = twoTeams() m.incidents = []api.Incident{{ID: 1}} m, cmd := press(t, m, "L") if cmd == nil { t.Fatal("L should sign out") } next, _ := m.Update(logoutDoneMsg{}) m = next.(Model) if m.mode != modeLogin || m.connected || len(m.incidents) != 0 { t.Errorf("expected the form with nothing loaded, got mode %v", m.mode) } if !strings.Contains(m.loginNote, "signed out") { t.Errorf("expected it to say so, got %q", m.loginNote) } } // Signing out and in again must not leave two refresh timers running, each // re-arming itself for ever. func TestSigningInAgainStartsNoSecondTimer(t *testing.T) { m := sized() next, _ := m.Update(connectedMsg{}) m = next.(Model) if !m.ticking { t.Fatal("the first connect starts the refresh timer") } m = m.requireLogin("x") if !m.ticking { t.Fatal("the timer is still running while signed out") } // Ticks while signed out must do nothing rather than fetch. if _, cmd := m.Update(tickMsg(time.Now())); cmd == nil { t.Error("the timer keeps ticking") } if cmd := m.refreshActiveSection(); cmd != nil { t.Error("no refresh should be attempted while signed out") } } func TestLoginView_HidesThePasswordAndShowsTheNote(t *testing.T) { m := signedOut("https://terdut.example.com").WithLogin("niklas", "api_key in config.yaml is no longer used") if m.loginFocus != loginPassword { t.Error("with the username known the cursor should start on the password") } m = typeInto(t, m, "hunter2-hunter2") view := m.View() for _, want := range []string{"Sign in to https://terdut.example.com", "niklas", "api_key in config.yaml is no longer used"} { if !strings.Contains(view, want) { t.Errorf("expected %q on the form:\n%s", want, view) } } if strings.Contains(view, "hunter2") { t.Error("the password must be masked") } }