refactor(deploy)!: TUI
This commit is contained in:
37 files changed
+3622
-1303
No files matched your search
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -508,242 +507,3 @@ func TestAccountableOSUser(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testRoot is the sanctioned placeholder root domain for tests (never a real host).
|
||||
const testRoot = "mc.example.net"
|
||||
|
||||
// advance feeds one message to the model and returns it re-typed as *bgModel, so the
|
||||
// state-machine assertions can read the resolved fields. The returned cmd is dropped:
|
||||
// these tests drive the gating transitions directly (authResultMsg / key presses)
|
||||
// rather than running the off-goroutine store commands.
|
||||
func advance(t *testing.T, m *bgModel, msg tea.Msg) *bgModel {
|
||||
t.Helper()
|
||||
next, _ := m.Update(msg)
|
||||
bm, ok := next.(*bgModel)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, want *bgModel", next)
|
||||
}
|
||||
return bm
|
||||
}
|
||||
|
||||
// enterProvisionViaMenu drives the top-level router into the Owner provision/reset
|
||||
// flow the way an operator does on the emergency path: the menu opens with option 1
|
||||
// (provision) focused, so a single Enter selects it. The gating sub-tests use this to
|
||||
// reach stepAuth (recovery) or stepProvision (bootstrap) through the REAL entry path
|
||||
// before asserting the accountability transitions — not by reaching past the menu.
|
||||
func enterProvisionViaMenu(t *testing.T, m *bgModel) *bgModel {
|
||||
t.Helper()
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("expected the model to open on stepMenu, got %v", m.step)
|
||||
}
|
||||
return advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
}
|
||||
|
||||
// TestBGModelGating drives the break-glass state machine headlessly to lock in the
|
||||
// accountability gate: a credential never advances to provisioning without either a
|
||||
// verified admin (recovery) or a deliberate, explicit OVERRIDE (root override), and
|
||||
// the resolved actor matches the path taken. This is the "which SysAdmin" guarantee.
|
||||
func TestBGModelGating(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("recovery starts at auth; a non-matching credential offers override, never provision", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepAuth || m.mode != "" {
|
||||
t.Fatalf("initial step/mode = %v/%q, want stepAuth and an unresolved mode", m.step, m.mode)
|
||||
}
|
||||
m = advance(t, m, authResultMsg{ok: false})
|
||||
if m.step != stepOverride {
|
||||
t.Errorf("after a non-matching credential step = %v, want stepOverride", m.step)
|
||||
}
|
||||
if m.mode == "recovery" {
|
||||
t.Error("mode must NOT become recovery on a failed credential — that would forge attribution")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recovery with a verified admin enters provision attributed to that admin", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{matched: "bob", ok: true})
|
||||
if m.step != stepProvision {
|
||||
t.Fatalf("step = %v, want stepProvision", m.step)
|
||||
}
|
||||
if m.mode != "recovery" || m.accountable != "bob" {
|
||||
t.Errorf("mode/accountable = %q/%q, want recovery/bob (the verified admin, not the OS user)", m.mode, m.accountable)
|
||||
}
|
||||
// Recovery generates the one-time password, so no password fields are shown.
|
||||
if len(m.inputs) != 2 {
|
||||
t.Errorf("recovery provision inputs = %d, want 2 (owner, email — no typed password)", len(m.inputs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an auth lookup error surfaces an error screen, not a silent override", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{err: errors.New("db unreachable")})
|
||||
if m.step != stepError || m.err == nil {
|
||||
t.Errorf("step/err = %v/%v, want stepError with a non-nil err", m.step, m.err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the root override requires the exact OVERRIDE token", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, authResultMsg{ok: false}) // → stepOverride
|
||||
m.inputs[0].SetValue("override") // wrong case must not pass
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepOverride || m.formErr == "" {
|
||||
t.Errorf("wrong token: step/formErr = %v/%q, want stay on stepOverride with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.mode == "root_override" {
|
||||
t.Error("mode must not flip to root_override without the exact token")
|
||||
}
|
||||
m.inputs[0].SetValue(breakGlassOverrideToken)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.mode != "root_override" || m.accountable != "alice" {
|
||||
t.Errorf("after OVERRIDE: step/mode/accountable = %v/%q/%q, want stepProvision/root_override/alice (the OS user)", m.step, m.mode, m.accountable)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty admin credentials do not start a verification", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // both inputs blank
|
||||
if m.step != stepAuth || m.formErr == "" {
|
||||
t.Errorf("blank submit: step/formErr = %v/%q, want stay on stepAuth with an error", m.step, m.formErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bootstrap starts at provision as the OS user and requires a valid, matching password", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{}
|
||||
m := newBGModel(ctx, f, testRoot, "", "", "deploybot", false)
|
||||
m = enterProvisionViaMenu(t, m)
|
||||
if m.step != stepProvision || m.mode != "bootstrap" || m.accountable != "deploybot" {
|
||||
t.Fatalf("initial step/mode/accountable = %v/%q/%q, want stepProvision/bootstrap/deploybot", m.step, m.mode, m.accountable)
|
||||
}
|
||||
if len(m.inputs) != 4 {
|
||||
t.Fatalf("bootstrap inputs = %d, want 4 (owner, email, password, confirm)", len(m.inputs))
|
||||
}
|
||||
// Too-short password is blocked, with no writes.
|
||||
m.inputs[2].SetValue("short")
|
||||
m.inputs[3].SetValue("short")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.formErr == "" {
|
||||
t.Errorf("weak password: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr)
|
||||
}
|
||||
// A mismatched confirmation is blocked.
|
||||
m.inputs[2].SetValue("valid-test-pw")
|
||||
m.inputs[3].SetValue("valid-test-XX")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepProvision || m.formErr == "" {
|
||||
t.Errorf("mismatch: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr)
|
||||
}
|
||||
if len(f.upserts) != 0 {
|
||||
t.Error("no owner should be provisioned while the form is invalid")
|
||||
}
|
||||
// Valid + matching advances to the working state (the write is dispatched).
|
||||
m.inputs[3].SetValue("valid-test-pw")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepWorking || m.ownerUsername != "owner" {
|
||||
t.Errorf("valid submit: step/owner = %v/%q, want stepWorking/owner", m.step, m.ownerUsername)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBGModelEdgeRouting locks in the setup-only Cloudflare edge flow WITHOUT
|
||||
// touching the operator's real Cloudflare account: setup option 2 reaches the edge
|
||||
// intro as an independent peer of Owner creation; breakGlass has no edge option;
|
||||
// hostnames are collected in the setup form; and invalid inputs are refused before
|
||||
// any cfsetup.Setup side effect. The real cloudflared/cert.pem detection and the
|
||||
// integration Setup (which shells out / calls the live API) are deliberately NOT exercised.
|
||||
func TestBGModelEdgeRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("setup menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) {
|
||||
f := &fakeOwnerStore{admins: true}
|
||||
m := newSetupBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("initial step = %v, want stepMenu", m.step)
|
||||
}
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepEdgeIntro {
|
||||
t.Fatalf("after selecting option 2 step = %v, want stepEdgeIntro", m.step)
|
||||
}
|
||||
// Reaching the edge must NOT have provisioned or reset an Owner.
|
||||
if len(f.upserts) != 0 {
|
||||
t.Error("the edge flow must not write any Owner record")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("esc from the edge intro returns to the setup router with the edge option highlighted", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if m.step != stepMenu || m.focus != 1 {
|
||||
t.Errorf("after esc step/focus = %v/%d, want stepMenu with the edge option (1) focused", m.step, m.focus)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("breakGlass has no edge option 2", func(t *testing.T) {
|
||||
m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||||
if m.step != stepMenu {
|
||||
t.Fatalf("breakGlass option 2 advanced to %v, want to stay on stepMenu", m.step)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge refuses empty credentials before any Cloudflare side effect", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
// Install the edge inputs directly: reaching them via the menu requires a real
|
||||
// cloudflared login (edgeReady()), which this unit test must not depend on.
|
||||
m.enterEdgeInput()
|
||||
if m.step != stepEdgeInput || len(m.inputs) != 7 {
|
||||
t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 7 inputs", m.step, len(m.inputs))
|
||||
}
|
||||
// All inputs blank: submit (via the real key path) must report an error and stay
|
||||
// put — NOT reach stepEdgeWorking, which is what launches cfsetup.Setup against
|
||||
// the live cloudflared binary / Cloudflare API.
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("blank edge submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("empty credentials must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true)
|
||||
m.enterEdgeInput()
|
||||
// Token + account present, but identity is a bare "@" (empty domain). This passes
|
||||
// the non-empty check yet must be refused before cfsetup.Setup, because an empty
|
||||
// EmailDomain admits no one — a silent lock-out the operator should fix.
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("@")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bare @ submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("a bare @ identity must never reach stepEdgeWorking — that would invoke the integration runner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitEdge requires an admin hostname and rejects URLs", func(t *testing.T) {
|
||||
m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true)
|
||||
m.enterEdgeInput()
|
||||
m.inputs[0].SetValue("token-value")
|
||||
m.inputs[1].SetValue("1234567890abcdef1234567890abcdef")
|
||||
m.inputs[2].SetValue("[email protected]")
|
||||
m.inputs[3].SetValue("https://console." + testRoot)
|
||||
m.inputs[4].SetValue("")
|
||||
m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.step != stepEdgeInput || m.formErr == "" {
|
||||
t.Errorf("bad hostnames: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr)
|
||||
}
|
||||
if m.step == stepEdgeWorking {
|
||||
t.Error("invalid hostnames must never reach stepEdgeWorking")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user