e0c5a5cba3
terdut-server v0.10.2 serves a web UI you sign in to with a password, and every user starts without one. Until now the only way to give somebody their first password was a curl call with an API key. p in Users sets the selected user's password. The form asks for the current password only in the one case the server checks it: you are changing your own password and already have one. The client has no other way to know who its key belongs to, so opening the form calls GET /api/me first and shows the fields once that answers. Setting someone else's password sends no current_password at all, rather than an empty one. Length (at least 10) and the repeated entry are checked before anything is sent, mirroring the server's rule so a typo costs no round trip. The server stays authoritative: a wrong current password comes back as its own 403 message on the dashboard. The status line says the user's other web sessions were signed out, because the server does that on every password change. API keys are not affected. Older servers have no /api/me. The client now returns a typed StatusError carrying the status code, so a 404 there reads as "needs terdut-server v0.10.2 or later" rather than a bare "server returned 404". Its Error() text is unchanged, so every existing message reads as before. Requires terdut-server v0.10.2 only for this form. Everything else works against the same servers as before.
205 lines
6.8 KiB
Go
205 lines
6.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// threeUsers is the Users section with the cursor on the last of three users.
|
|
func threeUsers() Model {
|
|
m := onUsers([]api.User{
|
|
{ID: 1, Username: "niklas"},
|
|
{ID: 2, Username: "anna"},
|
|
{ID: 3, Username: "erik"},
|
|
})
|
|
m.userManageTable.SetCursor(2)
|
|
return m
|
|
}
|
|
|
|
// The table used to see k before the section did, take it as "up", and the
|
|
// handler then opened API keys for the user above the one selected.
|
|
func TestUsers_APIKeysOpenForTheSelectedUser(t *testing.T) {
|
|
m, _ := press(t, threeUsers(), "k")
|
|
if m.mode != modeAPIKeyMenu {
|
|
t.Fatalf("expected the API key menu, got mode %v", m.mode)
|
|
}
|
|
if m.selectedUser.Username != "erik" {
|
|
t.Errorf("API keys opened for %s, want erik", m.selectedUser.Username)
|
|
}
|
|
}
|
|
|
|
// Same collision with d, which the table read as half a page down: the delete
|
|
// confirmation named a different user than the one under the cursor.
|
|
func TestUsers_DeleteTargetsTheSelectedUser(t *testing.T) {
|
|
m := threeUsers()
|
|
m.userManageTable.SetCursor(0)
|
|
m, _ = press(t, m, "d")
|
|
if m.mode != modeConfirm || m.selectedUser.Username != "niklas" {
|
|
t.Errorf("delete asked about %q in mode %v, want niklas", m.selectedUser.Username, m.mode)
|
|
}
|
|
}
|
|
|
|
func TestSchedule_DeleteTargetsTheSelectedDay(t *testing.T) {
|
|
m := sized()
|
|
m.activeSection = sectionSchedule
|
|
m.scheduleEntries = []api.ScheduleEntry{
|
|
{ID: 10, UserID: 1, Username: "niklas", Date: m.scheduleWindow.Format("2006-01-02")},
|
|
{ID: 11, UserID: 2, Username: "anna", Date: m.scheduleWindow.AddDate(0, 0, 1).Format("2006-01-02")},
|
|
}
|
|
m.scheduleDays = buildScheduleDays(m.scheduleWindow, m.scheduleEntries)
|
|
m.rebuildScheduleTable()
|
|
m.scheduleTable.SetCursor(0)
|
|
m, _ = press(t, m, "d")
|
|
if m.pendingDeleteEntry == nil || m.pendingDeleteEntry.ID != 10 {
|
|
t.Errorf("schedule delete targeted %+v, want entry 10", m.pendingDeleteEntry)
|
|
}
|
|
}
|
|
|
|
// f cycles the filter; it must not also page the cursor down.
|
|
func TestFilter_DoesNotMoveTheCursor(t *testing.T) {
|
|
m := sized()
|
|
m.incidents = make([]api.Incident, 40)
|
|
for i := range m.incidents {
|
|
m.incidents[i] = api.Incident{ID: int64(i + 1), Title: "x", Status: api.StatusTriggered, TriggeredAt: time.Now()}
|
|
}
|
|
m.rebuildIncidentTable()
|
|
m, _ = press(t, m, "f")
|
|
if c := m.incidentTable.Cursor(); c != 0 {
|
|
t.Errorf("f moved the cursor to %d", c)
|
|
}
|
|
}
|
|
|
|
// The arrow keys still move the users table, now that k is an action there.
|
|
func TestUsers_ArrowKeysStillNavigate(t *testing.T) {
|
|
m := threeUsers()
|
|
next, _ := m.Update(keyUp())
|
|
if c := next.(Model).userManageTable.Cursor(); c != 1 {
|
|
t.Errorf("up arrow left the cursor on %d, want 1", c)
|
|
}
|
|
}
|
|
|
|
func TestPassword_OpensForTheSelectedUserAndLooksUpWhoIAm(t *testing.T) {
|
|
m, cmd := press(t, threeUsers(), "p")
|
|
if m.mode != modePasswordSet || m.selectedUser.Username != "erik" {
|
|
t.Fatalf("expected the password form for erik, got mode %v for %q", m.mode, m.selectedUser.Username)
|
|
}
|
|
if !m.pwLoading || cmd == nil {
|
|
t.Error("the form should look up /api/me before it is usable")
|
|
}
|
|
if !strings.Contains(m.View(), "Checking who this key belongs to") {
|
|
t.Error("the form should say it is waiting")
|
|
}
|
|
}
|
|
|
|
func TestPassword_SomeoneElseNeedsNoCurrentPassword(t *testing.T) {
|
|
m, _ := press(t, threeUsers(), "p")
|
|
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
|
|
m = next.(Model)
|
|
if m.pwNeedCurrent || m.pwFocus != pwNew {
|
|
t.Errorf("setting erik's password as niklas should not ask for a current one")
|
|
}
|
|
if strings.Contains(m.View(), "Current password") {
|
|
t.Error("the current-password field should be hidden")
|
|
}
|
|
}
|
|
|
|
func TestPassword_OwnExistingPasswordNeedsCurrent(t *testing.T) {
|
|
m := threeUsers()
|
|
m.userManageTable.SetCursor(0)
|
|
m, _ = press(t, m, "p")
|
|
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
|
|
m = next.(Model)
|
|
if !m.pwNeedCurrent || m.pwFocus != pwCurrent {
|
|
t.Fatal("changing your own existing password should ask for the current one first")
|
|
}
|
|
m = typeInto(t, m, "correct horse")
|
|
m, _ = press(t, m, "tab")
|
|
m = typeInto(t, m, "a brand new secret")
|
|
m, _ = press(t, m, "tab")
|
|
m = typeInto(t, m, "a brand new secret")
|
|
m, cmd := press(t, m, "enter")
|
|
if cmd == nil || m.mode != modeDashboard {
|
|
t.Errorf("a complete form should submit (mode %v)", m.mode)
|
|
}
|
|
}
|
|
|
|
func TestPassword_OwnFirstPasswordNeedsNoCurrent(t *testing.T) {
|
|
m := threeUsers()
|
|
m.userManageTable.SetCursor(0)
|
|
m, _ = press(t, m, "p")
|
|
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: false}})
|
|
if next.(Model).pwNeedCurrent {
|
|
t.Error("there is no current password to ask for yet")
|
|
}
|
|
}
|
|
|
|
func TestPassword_RejectsBeforeSending(t *testing.T) {
|
|
cases := []struct{ name, pw, repeat, want string }{
|
|
{"too short", "short", "short", "at least 10"},
|
|
{"mismatch", "a brand new secret", "a different secret", "do not match"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, _ := press(t, threeUsers(), "p")
|
|
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}}})
|
|
m = typeInto(t, next.(Model), tc.pw)
|
|
m, _ = press(t, m, "tab")
|
|
m = typeInto(t, m, tc.repeat)
|
|
m, cmd := press(t, m, "enter")
|
|
if m.mode != modePasswordSet {
|
|
t.Error("the form should stay open")
|
|
}
|
|
if !strings.Contains(m.statusMsg, tc.want) {
|
|
t.Errorf("status %q should mention %q", m.statusMsg, tc.want)
|
|
}
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
// Only the clear-status timer may be scheduled, never a request.
|
|
if _, ok := cmd().(clearStatusMsg); !ok {
|
|
t.Error("nothing should be sent to the server")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPassword_EscapeCancels(t *testing.T) {
|
|
m, _ := press(t, threeUsers(), "p")
|
|
m, _ = press(t, m, "esc")
|
|
if m.mode != modeDashboard {
|
|
t.Errorf("esc should close the form, got mode %v", m.mode)
|
|
}
|
|
// A lookup arriving after the form closed must not reopen anything.
|
|
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 3}, HasPassword: true}})
|
|
if next.(Model).mode != modeDashboard {
|
|
t.Error("a late /api/me answer reopened the form")
|
|
}
|
|
}
|
|
|
|
func TestPassword_ErrorClosesTheFormWithAMessage(t *testing.T) {
|
|
m, _ := press(t, threeUsers(), "p")
|
|
next, _ := m.Update(userActionErrMsg{errTest("server returned 403: current password is incorrect")})
|
|
m = next.(Model)
|
|
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "current password is incorrect") {
|
|
t.Errorf("expected the server's message on the dashboard, got %q in mode %v", m.statusMsg, m.mode)
|
|
}
|
|
}
|
|
|
|
func typeInto(t *testing.T, m Model, s string) Model {
|
|
t.Helper()
|
|
next, _ := m.Update(runes(s))
|
|
return next.(Model)
|
|
}
|
|
|
|
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
|
|
|
|
func keyUp() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} }
|
|
|
|
type errTest string
|
|
|
|
func (e errTest) Error() string { return string(e) }
|