diff --git a/cmd/ota/main.go b/cmd/ota/main.go index 2cf8be7..680bdb4 100644 --- a/cmd/ota/main.go +++ b/cmd/ota/main.go @@ -57,6 +57,7 @@ func run(args []string, stdout, stderr *os.File) int { pollSec = fs.Int("poll-interval", 0, "override Logs tail poll interval in seconds") showVersion = fs.Bool("version", false, "print version and exit") checkMode = fs.Bool("check", false, "probe Okta API once and print a plain-text diagnostic (no TUI)") + readOnly = fs.Bool("readonly", false, "block every write op (edit forms, status pickers, palette write commands) and surface a toast") ) if err := fs.Parse(args); err != nil { // flag.ErrHelp is not an error — user asked for help. @@ -83,6 +84,7 @@ func run(args []string, stdout, stderr *os.File) int { TokenEnv: *tokenEnv, Debug: *debugMode, PollSec: *pollSec, + ReadOnly: *readOnly, }, stdout, stderr) } @@ -103,6 +105,7 @@ func run(args []string, stdout, stderr *os.File) int { TokenEnv: *tokenEnv, Debug: *debugMode, PollSec: *pollSec, + ReadOnly: *readOnly, }) var rootModel tea.Model = wireModel diff --git a/cmd/ota/readonly_test.go b/cmd/ota/readonly_test.go new file mode 100644 index 0000000..4d22cc6 --- /dev/null +++ b/cmd/ota/readonly_test.go @@ -0,0 +1,33 @@ +package main + +// QW-2 — smoke test that the --readonly CLI flag registers and parses +// cleanly. Combined with --version so run() short-circuits before it +// tries to launch the Bubbletea program. + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Run_ReadOnlyFlag_ParsesCleanly(t *testing.T) { + // t.Setenv isolates any lingering env; --version returns 0 without + // touching config so this test is safe in CI. + stdout, err := os.CreateTemp(t.TempDir(), "stdout") + require.NoError(t, err) + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + require.NoError(t, err) + + code := run([]string{"--readonly", "--version"}, stdout, stderr) + assert.Equal(t, 0, code, "`--readonly --version` must exit cleanly (flag registered)") + + // Confirm the unknown-flag path is NOT hit — bad flags print + // "flag provided but not defined" to stderr and return 2. + _ = stderr.Close() + stderrBytes, err := os.ReadFile(stderr.Name()) + require.NoError(t, err) + assert.NotContains(t, string(stderrBytes), "flag provided but not defined", + "--readonly must be a registered flag, not an unknown one") +} diff --git a/cmd/ota/wire.go b/cmd/ota/wire.go index 4f0fd9e..e255e88 100644 --- a/cmd/ota/wire.go +++ b/cmd/ota/wire.go @@ -29,6 +29,10 @@ type WireInput struct { TokenEnv string Debug bool PollSec int + // ReadOnly gates every UI write flow when true — edit forms, status + // pickers, and palette write commands surface a toast instead of + // running. Threaded through --readonly on the ota CLI (QW-2). + ReadOnly bool } // Wire is the single explicit dependency-assembly point. It loads config, @@ -134,6 +138,7 @@ func Wire(ctx context.Context, in WireInput) (app.Model, config.Config, error) { // override via cfg.OktaStatusEndpoint when self-hosted Okta // orgs run a different statuspage (rare). OktaStatusEndpoint: oktastatus.DefaultEndpoint, + ReadOnly: in.ReadOnly, }) return model, cfg, nil } diff --git a/internal/app/app.go b/internal/app/app.go index 6bb74ae..8f6c02a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -131,6 +131,18 @@ func (s Screen) String() string { // tests can assert against the router state without reaching into internals. func ActiveScreenName(m Model) string { return m.active.String() } +// isWriteScreen reports whether s is one of the edit-form screens the +// --readonly gate blocks entry to (QW-2). Palette / SwitchScreen paths +// route through this so `:edit`, `:group-edit`, etc. surface the same +// toast the OpenXEditMsg handlers do when read-only is active. +func isWriteScreen(s Screen) bool { + switch s { + case ScreenUserEdit, ScreenGroupEdit, ScreenRuleEdit, ScreenPolicyEdit: + return true + } + return false +} + // resetNav replaces the entire stack with the supplied root and // updates m.active to match. Used by `:` palette commands — // "navigate to " is the operator declaring a fresh root, so @@ -181,6 +193,24 @@ func (m Model) now() time.Time { return time.Now() } +// blockedByReadOnly reports whether a write-flow entry point should be +// short-circuited by the --readonly gate (QW-2). Returns (true, +// toastCmd) so callers can pattern-match: +// +// if blocked, cmd := m.blockedByReadOnly(); blocked { +// return m, cmd +// } +// +// The toast is the single canonical read-only feedback surface — +// every gated entry point uses the same text so operators recognise +// the guardrail regardless of which key they pressed. +func (m Model) blockedByReadOnly() (bool, tea.Cmd) { + if !m.deps.ReadOnly { + return false, nil + } + return true, toastCmdInfo("read-only mode — write ops disabled") +} + // Overlay identifies the active overlay, if any. type Overlay int @@ -387,6 +417,14 @@ type Deps struct { // outbound HTTP. main.go sets it to the public default. OktaStatusEndpoint string + // ReadOnly gates every UI write flow (edit form entry, status + // picker open, palette write commands like :reset-password / + // :unlock / :reset-mfa). When true the shell surfaces a toast + // and refuses to push the screen / open the modal. Read flows + // (list, detail, search, filter, logs tail) stay 100% functional. + // Threaded through the ota CLI's --readonly flag (QW-2). + ReadOnly bool + // Optional initial state for tests / direct embedding. InitialScreen Screen } @@ -680,6 +718,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // string back to the existing UserActionKind enum so the // y/N confirmation modal fires (issue #125 flow stays the // single source of truth for destructive ops). + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } switch msg.Kind { case "reset-password": return m.openActionConfirm(UserActionResetPassword) @@ -700,6 +741,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case shared.RunRuleActionMsg: // Group Rule lifecycle dispatcher (issue #188 v0.2.2). // Same gate-via-confirm pattern as RunUserActionMsg. + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } switch msg.Kind { case "activate": return m.openRuleActionConfirm(RuleActionActivate) @@ -739,6 +783,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // `:` palette commands declare a fresh root — wipe the nav // stack so the operator's mental model of "Esc walks back // through the chain I drilled into" stays clean. + if isWriteScreen(msg.Target) { + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } + } m.resetNav(msg.Target) m.overlay = OverlayNone m.paletteInput = "" @@ -746,6 +795,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return updated, cmd case SwitchScreenMsg: if s, ok := screenFromName(msg.Target); ok { + if isWriteScreen(s) { + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } + } m.resetNav(s) m.overlay = OverlayNone m.paletteInput = "" @@ -759,6 +813,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // wherever the operator came from rather than silently // dropping the trail. if s, ok := screenFromName(msg.Target); ok { + if isWriteScreen(s) { + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } + } m.pushNav(s) m.overlay = OverlayNone updated, cmd := m.ensureScreen(m.active) @@ -814,6 +873,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // rebuilding the EditModel so each entry fires a fresh GET // (AC-1.3 — cache distrust). Existing entries are discarded so // the operator never lands on a stale form when re-entering. + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } delete(m.screens, ScreenUserEdit) m.editTargetID = msg.ID m.pushNav(ScreenUserEdit) @@ -827,6 +889,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // is in a terminal state (no valid transitions) so the // operator gets actionable feedback instead of an empty // modal. + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } picker := NewUserStatusPickerModel(msg.User) if picker.Empty() { return m, toastCmdInfo("no status transitions for " + string(msg.User.Status)) @@ -835,6 +900,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = OverlayStatusPicker return m, nil case shared.OpenRuleStatusPickerMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } picker := NewRuleStatusPickerModel(msg.Rule) if picker.Empty() { return m, toastCmdInfo("no status transitions for " + string(msg.Rule.Status)) @@ -843,6 +911,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = OverlayStatusPicker return m, nil case shared.OpenPolicyStatusPickerMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } picker := NewPolicyStatusPickerModel(msg.Policy) if picker.Empty() { if msg.Policy.System { @@ -854,6 +925,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = OverlayStatusPicker return m, nil case shared.OpenAppStatusPickerMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } picker := NewAppStatusPickerModel(msg.App) if picker.Empty() { return m, toastCmdInfo("no status transitions for " + string(msg.App.Status)) @@ -862,6 +936,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = OverlayStatusPicker return m, nil case shared.OpenAuthenticatorStatusPickerMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } picker := NewAuthenticatorStatusPickerModel(msg.Authenticator) if picker.Empty() { return m, toastCmdInfo("no status transitions for " + string(msg.Authenticator.Status)) @@ -906,6 +983,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // buildScreen build a fresh EditModel that fires the initial // GET. Existing edit frame discarded so a re-entry never // lands on a stale form. + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } delete(m.screens, ScreenGroupEdit) m.groupEditTargetID = msg.ID m.pushNav(ScreenGroupEdit) @@ -938,6 +1018,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, refreshScreenCmd() case shared.OpenRuleEditMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } delete(m.screens, ScreenRuleEdit) m.ruleEditTargetID = msg.ID m.pushNav(ScreenRuleEdit) @@ -966,6 +1049,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, refreshScreenCmd() case shared.OpenPolicyEditMsg: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } delete(m.screens, ScreenPolicyEdit) m.policyEditTargetID = msg.ID m.pushNav(ScreenPolicyEdit) @@ -1898,6 +1984,20 @@ type filterInputStater interface { // drops trailing entries first. func (m Model) composeChromeBadges() []shared.ChromeBadge { var out []shared.ChromeBadge + // QW-2 — READONLY chrome badge leads the status row so operators + // see the guardrail at a glance. Warning tone (yellow) mirrors the + // server-filter / query prompts — same "you're operating a modal + // gate" visual cue, distinct from the red Danger tone reserved for + // mid-flight destructive ops. Value is set to "on" so the + // formatStatusBadge path applies the Warning tone (bare Value + // collapses to a muted `[KEY]` rendering that ignores Tone). + if m.deps.ReadOnly { + out = append(out, shared.ChromeBadge{ + Key: "READONLY", + Value: "on", + Tone: shared.BadgeWarning, + }) + } if m.overlay == OverlayActionConfirm { switch { case m.pendingRule.Kind != RuleActionNone: @@ -2834,10 +2934,19 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, c } case paletteCmdResetPassword: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } return m.openActionConfirm(UserActionResetPassword) case paletteCmdUnlock: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } return m.openActionConfirm(UserActionUnlock) case paletteCmdResetFactors: + if blocked, cmd := m.blockedByReadOnly(); blocked { + return m, cmd + } return m.openActionConfirm(UserActionResetFactors) case paletteCmdPolicyType: // Issue #165: jump straight to the typed list, replacing diff --git a/internal/app/readonly_test.go b/internal/app/readonly_test.go new file mode 100644 index 0000000..d1e9b9b --- /dev/null +++ b/internal/app/readonly_test.go @@ -0,0 +1,153 @@ +package app_test + +// QW-2 — --readonly CLI flag blocks every write-flow entry point and +// surfaces a "read-only mode — write ops disabled" toast, plus stamps +// a READONLY chrome badge for the session. + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tedilabs/ota/internal/app" + "github.com/tedilabs/ota/internal/clock" + "github.com/tedilabs/ota/internal/domain" + "github.com/tedilabs/ota/internal/keys" + "github.com/tedilabs/ota/internal/tui/shared" +) + +func newReadOnlyApp(t *testing.T) app.Model { + t.Helper() + keymap, _, err := keys.Resolve(nil) + require.NoError(t, err) + return app.New(app.Deps{ + ReadOnly: true, + Clock: clock.Real(), + Keys: keymap, + }) +} + +// assertReadOnlyToast runs the returned cmd, ensures it emits a +// ToastMsg matching the canonical read-only text. +func assertReadOnlyToast(t *testing.T, cmd tea.Cmd) { + t.Helper() + require.NotNil(t, cmd, "read-only gate must emit a toast Cmd") + msg := cmd() + toast, ok := msg.(app.ToastMsg) + require.Truef(t, ok, "cmd must produce app.ToastMsg, got %T", msg) + assert.Contains(t, toast.Text, "read-only mode", + "toast text must call out the read-only mode guardrail") +} + +// QW-2 — OpenUserEditMsg must not push ScreenUserEdit and must fire +// the read-only toast when Deps.ReadOnly is set. +func Test_ReadOnly_OpenUserEditMsg_BlockedWithToast(t *testing.T) { + t.Parallel() + m := newReadOnlyApp(t) + + updated, cmd := m.Update(shared.OpenUserEditMsg{ID: "00u_alice"}) + updatedModel, ok := updated.(app.Model) + require.True(t, ok) + + assert.NotEqual(t, "user-edit", app.ActiveScreenName(updatedModel), + "read-only mode must not push ScreenUserEdit") + assertReadOnlyToast(t, cmd) +} + +// QW-2 — OpenStatusPickerMsg must not open the status picker overlay +// when read-only is active. +func Test_ReadOnly_OpenStatusPickerMsg_BlockedWithToast(t *testing.T) { + t.Parallel() + m := newReadOnlyApp(t) + + user := domain.User{ID: "00u_alice", Status: domain.UserStatusActive} + updated, cmd := m.Update(shared.OpenStatusPickerMsg{User: user}) + updatedModel, ok := updated.(app.Model) + require.True(t, ok) + + assert.Equal(t, app.OverlayNone, updatedModel.Overlay(), + "read-only mode must not open the status picker overlay") + assertReadOnlyToast(t, cmd) +} + +// QW-2 — palette write commands like :reset-password are gated too. +func Test_ReadOnly_PaletteResetPassword_BlockedWithToast(t *testing.T) { + t.Parallel() + m := newReadOnlyApp(t) + + // `:` returns openCmdPaletteCmd. Run it and feed the resulting + // Msg back so the overlay actually opens before typing the verb. + _, openCmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) + require.NotNil(t, openCmd, "`:` must return the openCmdPaletteCmd") + next, _ := m.Update(openCmd()) + mm, ok := next.(app.Model) + require.True(t, ok) + require.Equal(t, app.OverlayPalette, mm.Overlay(), + "`:` must open the palette overlay") + + for _, r := range "reset-password" { + next, _ = mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mm = next.(app.Model) + } + next, cmd := mm.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mm, ok = next.(app.Model) + require.True(t, ok) + + assert.Equal(t, app.OverlayNone, mm.Overlay(), + "palette write command must not open the action-confirm overlay in read-only mode") + assertReadOnlyToast(t, cmd) +} + +// QW-2 — shared.OpenScreenMsg is the cross-screen drill-down path +// (e.g., a Detail row jumping straight to an edit form). screenFromName +// resolves write-screen aliases like "user-edit" / "edit" too, so this +// handler must apply the same read-only gate SwitchScreenMsg and +// ScreenChangeMsg use — otherwise a drill-down bypasses --readonly. +func Test_ReadOnly_OpenScreenMsg_UserEdit_BlockedWithToast(t *testing.T) { + t.Parallel() + m := newReadOnlyApp(t) + + before := app.ActiveScreenName(m) + updated, cmd := m.Update(shared.OpenScreenMsg{Target: "user-edit"}) + updatedModel, ok := updated.(app.Model) + require.True(t, ok) + + assert.Equal(t, before, app.ActiveScreenName(updatedModel), + "read-only mode must not push ScreenUserEdit via OpenScreenMsg") + assert.NotEqual(t, "user-edit", app.ActiveScreenName(updatedModel), + "active screen must not become user-edit under --readonly") + assertReadOnlyToast(t, cmd) +} + +// QW-2 — the READONLY chrome badge shows up in the rendered View so +// operators can see the guardrail at a glance. +func Test_ReadOnly_View_StampsReadOnlyBadge(t *testing.T) { + t.Parallel() + m := newReadOnlyApp(t) + // Prime the chrome with a WindowSizeMsg so View() renders the full + // chrome (title bar + status row + body + footer). + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + mm, ok := next.(app.Model) + require.True(t, ok) + view := mm.View() + assert.True(t, strings.Contains(view, "READONLY"), + "chrome must stamp the READONLY badge when Deps.ReadOnly is true") +} + +// QW-2 — read-only must be off by default so the badge does not show +// up on a stock boot. +func Test_ReadOnly_Default_False_NoBadge(t *testing.T) { + t.Parallel() + keymap, _, err := keys.Resolve(nil) + require.NoError(t, err) + m := app.New(app.Deps{Clock: clock.Real(), Keys: keymap}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + mm, ok := next.(app.Model) + require.True(t, ok) + view := mm.View() + assert.False(t, strings.Contains(view, "READONLY"), + "stock boot must not stamp the READONLY badge") +}