package tui import ( "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/session" tea "github.com/charmbracelet/bubbletea" ) // fakeServer is the device-login half of terdut-server: it starts a login, // answers polls with whatever poll says, and records what it was asked. type fakeServer struct { *httptest.Server mu sync.Mutex polls int // poll is called for each poll and writes the response. poll func(w http.ResponseWriter, n int) } func newFakeServer(t *testing.T) *fakeServer { t.Helper() f := &fakeServer{} f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/oidc/device": io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK", "verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`) case "/api/oidc/device/token": f.mu.Lock() f.polls++ n := f.polls f.mu.Unlock() var body struct { DeviceCode string `json:"device_code"` } json.NewDecoder(r.Body).Decode(&body) if body.DeviceCode != "dev-1" { t.Errorf("polled with %q", body.DeviceCode) } f.poll(w, n) default: http.NotFound(w, r) } })) t.Cleanup(f.Close) return f } func pending(w http.ResponseWriter, _ int) { w.WriteHeader(http.StatusAccepted) io.WriteString(w, `{"status":"pending"}`) } // offering is a signed-out model that has been told what the server offers. func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) { t.Helper() m := signedOut(url).WithAuth(pref) next, cmd := m.Update(authConfigMsg{cfg}) return next.(Model), cmd } func both() api.AuthConfig { c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true} c.OIDC.Enabled, c.OIDC.Name = true, "Authentik" return c } func ssoOnly() api.AuthConfig { c := both() c.PasswordLogin = false return c } func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { t.Helper() next, cmd := m.Update(msg) return next.(Model), cmd } func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} } func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) { m, cmd := offering(t, "http://test", both(), "") if cmd != nil || m.sso.active { t.Fatal("with passwords on offer nothing should start until asked") } view := m.View() for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} { if !strings.Contains(view, want) { t.Errorf("expected %q on the form:\n%s", want, view) } } } func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) { m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "") if strings.Contains(m.View(), "ctrl+o") { t.Error("a server with no SSO must not advertise it") } if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active { t.Error("ctrl+o must do nothing when the server has no SSO") } } func TestSSO_FullFlow(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) f := newFakeServer(t) f.poll = func(w http.ResponseWriter, n int) { if n < 3 { pending(w, n) return } http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"}) io.WriteString(w, `{"user":{}}`) } m, _ := offering(t, f.URL, both(), "") // ctrl+o asks the server for a login. m, cmd := update(t, m, ctrlO()) if !m.sso.active || cmd == nil { t.Fatal("ctrl+o should start a single sign-on login") } if !strings.Contains(m.View(), "Contacting the server") { t.Errorf("before the server answers:\n%s", m.View()) } started, ok := cmd().(deviceStartedMsg) if !ok { t.Fatalf("expected deviceStartedMsg, got %#v", cmd()) } // The link and the code are shown, and a poll is scheduled. m, cmd = update(t, m, started) if cmd == nil || m.sso.interval != 5*time.Second { t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval) } view := m.View() for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK", "BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} { if !strings.Contains(view, want) { t.Errorf("expected %q while waiting:\n%s", want, view) } } if strings.Contains(view, "Username:") { t.Error("the password form must be out of the way while waiting") } // Two polls that find nothing, each scheduling the next. for i := 1; i <= 2; i++ { m, cmd = update(t, m, devicePollMsg{m.sso.attempt}) if cmd == nil { t.Fatalf("poll %d: expected a request", i) } pend, ok := cmd().(devicePendingMsg) if !ok { t.Fatalf("poll %d: expected devicePendingMsg", i) } if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active { t.Fatalf("poll %d: expected to keep waiting", i) } } // The third is approved: the session is saved and it moves on and connects. m, cmd = update(t, m, devicePollMsg{m.sso.attempt}) done := cmd() if _, ok := done.(loginDoneMsg); !ok { t.Fatalf("expected loginDoneMsg, got %#v", done) } if got := session.Load(f.URL); got != "tok-sso" { t.Errorf("session saved for next time: %q", got) } m, connect := update(t, m, done) if m.mode != modeDashboard || m.sso.active || connect == nil { t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active) } if f.polls != 3 { t.Errorf("%d polls, want 3", f.polls) } } func TestSSO_EscCancelsBeforeItQuits(t *testing.T) { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}}) m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}) if cmd != nil { t.Fatal("the first esc backs out of the wait; it must not quit") } if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") { t.Errorf("expected the password form back, active %v", m.sso.active) } if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil { t.Error("esc on the form quits, as it always did") } else if _, ok := cmd().(tea.QuitMsg); !ok { t.Errorf("expected a quit, got %#v", cmd()) } } // The answers of an attempt that was cancelled arrive late and must change nothing. func TestSSO_StaleMessagesAreIgnored(t *testing.T) { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) old := m.sso.attempt m = m.cancelSSO() for name, msg := range map[string]tea.Msg{ "started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}}, "poll": devicePollMsg{old}, "pending": devicePendingMsg{attempt: old}, "failed": deviceFailedMsg{old, api.ErrDeviceExpired}, } { next, cmd := update(t, m, msg) if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" { t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr) } } // A new attempt is not confused by the old one's messages either. m, _ = update(t, m, ctrlO()) if m.sso.attempt == old { t.Fatal("a new attempt must have a new number") } if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil { t.Error("the old attempt's poll must not run in the new one") } } func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) { m, cmd := offering(t, "http://test", ssoOnly(), "") if !m.sso.active || cmd == nil { t.Fatal("a server with no passwords should start signing in with SSO straight away") } m = m.cancelSSO() view := m.View() if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") { t.Errorf("no password form on an SSO-only server:\n%s", view) } if !strings.Contains(view, "enter·sign in with Authentik") { t.Errorf("the footer should say what enter does:\n%s", view) } if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil { t.Error("enter should start it again") } } func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) { if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil { t.Error("auth: sso should start by itself when the server offers it") } // ...and shows the password form when it does not, rather than a dead end. m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso") if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") { t.Error("auth: sso against a server without SSO must fall back to the form") } if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil { t.Error("auth: password must not start SSO") } } func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) { for name, tc := range map[string]struct { err error want string }{ "expired": {api.ErrDeviceExpired, "expired"}, "refused": {api.ErrDeviceDenied, "refused"}, "no sso": {&api.StatusError{Code: 404}, "does not offer"}, "limited": {&api.StatusError{Code: 429}, "too many"}, "other": {errors.New("dial tcp: refused"), "Authentik failed"}, } { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err}) if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) { t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want) } if !strings.Contains(m.View(), tc.want) { t.Errorf("%s: the reason is not shown:\n%s", name, m.View()) } } } func TestSSO_SlowDownLengthensTheInterval(t *testing.T) { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}}) m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true}) if m.sso.interval != 10*time.Second || cmd == nil { t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil) } } func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}}) blip := errors.New("connection reset") // Two failures, then a good answer: the count starts over. for range 2 { m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip}) } m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt}) if !m.sso.active || m.sso.failures != 0 { t.Fatalf("a good answer should reset the failures: %+v", m.sso) } // Three in a row is a dead connection. var cmd tea.Cmd for range maxPollFailures { m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip}) } if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") { t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr) } } func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) { m, _ := offering(t, "http://test", both(), "") m, _ = update(t, m, ctrlO()) m = typeInto(t, m, "hunter2") if got := m.loginInputs[m.loginFocus].Value(); got != "" { t.Errorf("keys typed during the wait ended up in a field: %q", got) } if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil { t.Error("enter during the wait must not start anything") } } // After the session ends the form comes back; it must still know what the // server offers, and follow the config's preference without a second question. func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) { m, _ := offering(t, "http://test", both(), "sso") m = m.cancelSSO() m.mode = modeDashboard // signed in, as loginDoneMsg leaves it m, _ = update(t, m, connectedMsg{}) m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}}) if m.mode != modeLogin || m.authInfo == nil { t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo) } if !m.sso.active || cmd == nil { t.Error("with auth: sso an ended session should go straight to SSO") } } func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) { m, _ := offering(t, "http://test", both(), "sso") m = m.cancelSSO() m.mode = modeDashboard m, _ = update(t, m, connectedMsg{}) m, cmd := update(t, m, logoutDoneMsg{}) if m.mode != modeLogin || m.sso.active || cmd != nil { t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active) } } // A session that ends before the server was ever asked (the TUI started on a // saved session) still has to learn what to offer. func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) { c := api.NewClient("http://test") c.SetSession("saved") m := NewModel(c, "http://test", time.Minute, signedOut("x").theme) m.width, m.height = 120, 40 m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}}) if m.mode != modeLogin || m.authInfo != nil || cmd == nil { t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil) } }