ee25552a53
The sign-in screen asks the server how it can be signed in to (GET /api/auth/config) and offers what it finds: the password form, and "Sign in with <provider>" when the server can do a device login. The TUI shows a link and a short code, the person approves it in any browser, and the next poll hands over the ordinary session, so it works over SSH where no browser can be opened. The terminal never talks to the identity provider. The password form is hidden when the server has turned password login off. `auth: sso` in config.yaml starts the SSO login straight away, but not right after signing out, where that would sign the person straight back in; any other value is refused when the config is read. Polling honours the server's interval, backs off on slow_down, and gives up after repeated failures rather than retrying forever. A server without /api/auth/config answers 404 and is treated as passwords only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0 for SSO.
256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|