refactor(deploy): improved cloudflare walkthrough
This commit is contained in:
12 files changed
+837
-362
No files matched your search
@@ -83,7 +83,15 @@ func (m *hostBootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *hostBootstrapModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Host Bootstrap"))
|
||||
// Share the wizard's progress rail so bootstrap reads as step 1 of one
|
||||
// continuous flow rather than a separate popup with its own banner. Cell 0
|
||||
// (Bootstrap) is active while installing; once it completes we light cell 1
|
||||
// (Preflight) to foreshadow the hand-off to the wizard that runs next.
|
||||
railAt := 0
|
||||
if m.state == hostBootstrapDone {
|
||||
railAt = 1
|
||||
}
|
||||
b.WriteString(tuiStepRail(setupRailSteps, railAt) + "\n\n")
|
||||
|
||||
switch m.state {
|
||||
case hostBootstrapIntro:
|
||||
@@ -105,6 +113,9 @@ func (m *hostBootstrapModel) View() string {
|
||||
if m.err != nil {
|
||||
b.WriteString(tuiHint.Render(m.err.Error()) + "\n")
|
||||
}
|
||||
// The installer streams on the normal screen during the run; alt-screen
|
||||
// restores it on exit, so the full log is still there to inspect.
|
||||
b.WriteString(tuiHint.Render("The installer's full output remains on screen after you exit.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "retry", "esc", "exit"))
|
||||
}
|
||||
@@ -128,7 +139,10 @@ func (m *hostBootstrapModel) runBootstrap() tea.Cmd {
|
||||
}
|
||||
|
||||
func runHostBootstrapTUI(ctx context.Context) (bool, error) {
|
||||
final, err := tea.NewProgram(newHostBootstrapModel(ctx)).Run()
|
||||
// Alt-screen matches the wizard's locked, clear-screen chrome so the two
|
||||
// programs feel like one flow. tea.ExecProcess drops out of alt-screen for the
|
||||
// installer (its output streams on the normal screen) and restores it after.
|
||||
final, err := tea.NewProgram(newHostBootstrapModel(ctx), tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -43,12 +43,11 @@ func (m *connectChooserModel) build() *huh.Form {
|
||||
huh.NewOption("Cloudflare Tunnel + Access · no open ports", connectCloudflare),
|
||||
huh.NewOption("Reverse proxy (bring your own) · guided", connectReverseProxy),
|
||||
),
|
||||
huh.NewNote().
|
||||
Title("⚠ Security").
|
||||
Description(
|
||||
"With Local or reverse proxy, anyone who can reach the admin hostname can attempt "+
|
||||
"login — the admin console is gated by your Owner password alone. "+
|
||||
"Cloudflare Access adds an edge check in front of it."),
|
||||
// A dim, untitled footnote — deliberately subordinate to the picker above
|
||||
// so the screen reads as a menu, not an info page.
|
||||
huh.NewNote().Description(
|
||||
"⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+
|
||||
"Cloudflare Access adds an edge check in front."),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -113,6 +112,10 @@ func (m *connectChooserModel) chooseLocal() tea.Cmd {
|
||||
|
||||
func (m *connectChooserModel) View() string { return m.form.View() }
|
||||
|
||||
// arrowNavOK lets the root repurpose ←/→ to walk the step rail: this screen
|
||||
// navigates its options with ↑/↓, so the horizontal arrows are free.
|
||||
func (m *connectChooserModel) arrowNavOK() bool { return true }
|
||||
|
||||
// ---- Reverse proxy: collect hostnames, record them, render a guide ----
|
||||
|
||||
type rpStep int
|
||||
|
||||
+301
-297
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,33 +13,35 @@ import (
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// edgeModel publishes the panel via Cloudflare Tunnel + Access. It shares the
|
||||
// wizard's chrome with every other connection screen: no banner of its own (the
|
||||
// root paints the step rail), one huh form for input (the two credential/host
|
||||
// groups), and the same locked, clear-screen styling as Local and Reverse-proxy.
|
||||
// The intro/working/done/error states stay custom — they show status and actions
|
||||
// rather than collect input — but they use the shared widgets so nothing reads
|
||||
// as a separate popup.
|
||||
type egStep int
|
||||
|
||||
const (
|
||||
egIntro egStep = iota
|
||||
egAuth
|
||||
egConfig
|
||||
egForm
|
||||
egWorking
|
||||
egDone
|
||||
egError
|
||||
)
|
||||
|
||||
type egAuthDoneMsg struct {
|
||||
token string
|
||||
account string
|
||||
err error
|
||||
}
|
||||
|
||||
type egLoginDoneMsg struct{ err error }
|
||||
|
||||
type egInstallDoneMsg struct{ err error }
|
||||
|
||||
type egSetupDoneMsg struct {
|
||||
result *cfsetup.Result
|
||||
progress []string
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -49,39 +52,50 @@ type edgeModel struct {
|
||||
adminHostname string
|
||||
panelHostname string
|
||||
|
||||
// cloudflared detection
|
||||
// cloudflared detection (intro gate)
|
||||
cloudflaredPath string
|
||||
certExists bool
|
||||
installing bool
|
||||
loginNote string
|
||||
|
||||
// auth step inputs
|
||||
authInputs []textinput.Model
|
||||
authFocus int
|
||||
authErr string
|
||||
// form-bound inputs (one huh form, two groups: credentials + hosts)
|
||||
form *huh.Form
|
||||
authToken string
|
||||
authAccount string
|
||||
|
||||
// config step inputs
|
||||
cfgInputs []textinput.Model
|
||||
cfgFocus int
|
||||
cfgErr string
|
||||
identity string
|
||||
panelHost string
|
||||
adminHost string
|
||||
tunnelName string
|
||||
cfgPath string
|
||||
|
||||
// working / result
|
||||
sp spinner.Model
|
||||
working string
|
||||
lastErr error
|
||||
prog []string
|
||||
result *cfsetup.Result
|
||||
panelSet string
|
||||
adminSet string
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newEdgeModel(rootDomain, adminHost, panelHost string) *edgeModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = tuiLabel
|
||||
|
||||
m := &edgeModel{
|
||||
step: egIntro,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHost,
|
||||
panelHostname: panelHost,
|
||||
sp: sp,
|
||||
// Prefill the host/identity fields so the common case is enter-through.
|
||||
panelHost: defaultPanelHostname(rootDomain, panelHost),
|
||||
adminHost: defaultAdminHostname(rootDomain, adminHost),
|
||||
tunnelName: defaultTunnelName,
|
||||
cfgPath: defaultTunnelConfigPath,
|
||||
}
|
||||
m.detectCloudflared()
|
||||
return m
|
||||
@@ -93,8 +107,134 @@ func (m *edgeModel) detectCloudflared() {
|
||||
m.certExists = pre.CertExists
|
||||
}
|
||||
|
||||
// build assembles the single two-group form. Group 1 collects the API
|
||||
// credentials; group 2 the hostnames and who Access admits. huh owns the
|
||||
// inter-group navigation natively — enter/tab advances (and runs that group's
|
||||
// validators first), shift+tab from the top of group 2 steps back to group 1,
|
||||
// and esc aborts the whole form, which we treat as "back to the intro". This is
|
||||
// why edge no longer hand-manages two separate input screens.
|
||||
func (m *edgeModel) build() *huh.Form {
|
||||
return m.sized(newFelisForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Cloudflare credentials").
|
||||
Description("Create a scoped Bearer token (Account › Access: Edit) at:\n"+cloudflareAccessTokenTemplateURL),
|
||||
huh.NewInput().
|
||||
Title("API token").
|
||||
Description("starts cfat_… — not your Global API Key").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
CharLimit(200).
|
||||
Value(&m.authToken).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("a Cloudflare API token is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Account ID").
|
||||
Description("32-char hex from the dashboard URL: dash.cloudflare.com/<this>").
|
||||
CharLimit(64).
|
||||
Value(&m.authAccount).
|
||||
Validate(validateAccountID),
|
||||
).Title("Step 1 · Credentials"),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Admit").
|
||||
Description("Who Access lets in: your email, or @your-domain").
|
||||
CharLimit(254).
|
||||
Value(&m.identity).
|
||||
Validate(validateAccessIdentity),
|
||||
huh.NewInput().
|
||||
Title("Player console hostname").
|
||||
CharLimit(253).
|
||||
Value(&m.panelHost).
|
||||
Validate(func(s string) error {
|
||||
return validateEdgeHostname("player console", normalizeEdgeHostname(s), false)
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Admin console hostname").
|
||||
Description("fronted by Cloudflare Access").
|
||||
CharLimit(253).
|
||||
Value(&m.adminHost).
|
||||
Validate(func(s string) error {
|
||||
admin := normalizeEdgeHostname(s)
|
||||
if err := validateEdgeHostname("admin console", admin, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if panel := normalizeEdgeHostname(m.panelHost); panel != "" && strings.EqualFold(panel, admin) {
|
||||
return errors.New("player and admin console hostnames must be different")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Tunnel name").
|
||||
CharLimit(64).
|
||||
Value(&m.tunnelName),
|
||||
huh.NewInput().
|
||||
Title("Tunnel config path").
|
||||
CharLimit(256).
|
||||
Value(&m.cfgPath),
|
||||
).Title("Step 2 · Hostnames & identity"),
|
||||
))
|
||||
}
|
||||
|
||||
// validateAccountID accepts the 32-char hex account id and gives a targeted nudge
|
||||
// when the operator pastes the API token into the wrong field.
|
||||
func validateAccountID(s string) error {
|
||||
a := strings.TrimSpace(s)
|
||||
if a == "" {
|
||||
return errors.New("the Cloudflare account ID is required")
|
||||
}
|
||||
if !isHex32(a) {
|
||||
if strings.HasPrefix(a, "cfat_") {
|
||||
return errors.New("that looks like an API token — the Account ID is a 32-char hex string")
|
||||
}
|
||||
return errors.New("the Account ID must be 32 hex characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAccessIdentity accepts an email or an @domain wildcard.
|
||||
func validateAccessIdentity(s string) error {
|
||||
id := strings.TrimSpace(s)
|
||||
if id == "" {
|
||||
return errors.New("enter who Access should admit")
|
||||
}
|
||||
if strings.HasPrefix(id, "@") && strings.TrimPrefix(id, "@") == "" {
|
||||
return errors.New("enter a domain after the @, e.g. @your-domain")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) sized(f *huh.Form) *huh.Form {
|
||||
if m.width > 0 {
|
||||
return f.WithWidth(m.width).WithHeight(m.height)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *edgeModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
m.form = m.form.WithWidth(w).WithHeight(h)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) Init() tea.Cmd { return nil }
|
||||
|
||||
// arrowNavOK yields ←/→ to the root's step rail only when no text field is
|
||||
// focused: the intro and the terminal states take single-key actions, so the
|
||||
// horizontal arrows are free, but while the form is up they belong to the cursor.
|
||||
func (m *edgeModel) arrowNavOK() bool {
|
||||
switch m.step {
|
||||
case egForm, egWorking:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case egLoginDoneMsg:
|
||||
@@ -118,18 +258,9 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case egAuthDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.authErr = msg.err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.authToken = msg.token
|
||||
m.authAccount = msg.account
|
||||
m.step = egConfig
|
||||
return m, m.enterConfig()
|
||||
|
||||
case egSetupDoneMsg:
|
||||
m.working = ""
|
||||
m.prog = msg.progress
|
||||
if msg.err != nil {
|
||||
m.step = egError
|
||||
m.lastErr = msg.err
|
||||
@@ -137,24 +268,108 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.step = egDone
|
||||
m.result = msg.result
|
||||
if msg.result != nil {
|
||||
m.prog = msg.result.Progress
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.step == egWorking {
|
||||
var cmd tea.Cmd
|
||||
m.sp, cmd = m.sp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
return m.handleIntroKey(msg)
|
||||
case egWorking:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case egDone:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
return m, nil
|
||||
case egError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
// Re-run with the same (still-valid) inputs. Setup is idempotent,
|
||||
// so a retry recovers cleanly from a transient failure.
|
||||
return m, m.startSetup()
|
||||
case "esc":
|
||||
m.step = egForm
|
||||
m.lastErr, m.prog = nil, nil
|
||||
m.form = m.build()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
return m, nil
|
||||
case egForm:
|
||||
// Intercept the exits before huh sees them: huh collapses esc and ctrl+c
|
||||
// into a single StateAborted, so we can't tell them apart afterward.
|
||||
// esc steps back to the intro/status screen; ctrl+c quits, matching every
|
||||
// sibling screen. Any other key falls through to the form below.
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
m.step = egIntro
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.step == egForm && m.form != nil {
|
||||
form, cmd := m.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
m.form = f
|
||||
}
|
||||
switch m.form.State {
|
||||
case huh.StateCompleted:
|
||||
return m, m.startSetup()
|
||||
case huh.StateAborted:
|
||||
// esc on the form steps back to the intro/status screen.
|
||||
m.step = egIntro
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Cloudflare Edge"))
|
||||
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports, TLS and admin identity handled by Cloudflare. Press esc to pick a different method.") + "\n\n")
|
||||
case egForm:
|
||||
if m.form == nil {
|
||||
return ""
|
||||
}
|
||||
return m.form.View()
|
||||
|
||||
case egWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Configuring Cloudflare Tunnel + Access edge…"
|
||||
}
|
||||
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
|
||||
|
||||
case egDone:
|
||||
return m.doneView()
|
||||
|
||||
case egError:
|
||||
return m.errorView()
|
||||
|
||||
default:
|
||||
return m.introView()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) introView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports; TLS and the admin identity check are handled at Cloudflare's edge.") + "\n\n")
|
||||
b.WriteString(tuiLabel.Render("Status") + "\n")
|
||||
if m.cloudflaredPath == "" {
|
||||
b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n")
|
||||
@@ -166,6 +381,9 @@ func (m *edgeModel) View() string {
|
||||
b.WriteString(" " + tuiWarn.Render("⟳ not logged in") + " — press l for browser login\n\n")
|
||||
}
|
||||
}
|
||||
if m.installing {
|
||||
b.WriteString(" " + tuiIconSpin + " " + tuiHint.Render("downloading cloudflared…") + "\n\n")
|
||||
}
|
||||
if m.loginNote != "" {
|
||||
b.WriteString(tuiHint.Render(m.loginNote) + "\n\n")
|
||||
}
|
||||
@@ -173,7 +391,7 @@ func (m *edgeModel) View() string {
|
||||
b.WriteString(tuiHint.Render("Player console: "+panel) + "\n")
|
||||
}
|
||||
if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" {
|
||||
b.WriteString(tuiHint.Render("Admin console: "+admin) + " (Access-guarded)\n")
|
||||
b.WriteString(tuiHint.Render("Admin console: "+admin) + tuiHint.Render(" (Access-guarded)") + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
switch {
|
||||
@@ -184,116 +402,45 @@ func (m *edgeModel) View() string {
|
||||
default:
|
||||
b.WriteString(tuiAction("l", "login", "esc", "back"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
case egAuth:
|
||||
b.WriteString(tuiHint.Render("Step 1/2: Enter your Cloudflare credentials.") + "\n\n")
|
||||
b.WriteString(tuiWizardCard("API Token & Account ID",
|
||||
"Create a Bearer token at: "+cloudflareAccessTokenTemplateURL,
|
||||
tuiFormField("API token", m.authInputs[0])+"\n\n"+
|
||||
tuiFormField("Account ID", m.authInputs[1])))
|
||||
b.WriteString("\n" + tuiInfo("Account ID is in the Cloudflare Dashboard URL: dash.cloudflare.com/<this-part>") + "\n")
|
||||
if m.authErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.authErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "continue", "esc", "back"))
|
||||
|
||||
case egConfig:
|
||||
b.WriteString(tuiHint.Render("Step 2/2: Choose hostnames and who gets access.") + "\n\n")
|
||||
labels := []string{"Admit (your email, or @your-domain)", "Player console hostname", "Admin console hostname", "Tunnel name", "Config path"}
|
||||
var fields string
|
||||
for i, lbl := range labels {
|
||||
if i > 0 {
|
||||
fields += "\n\n"
|
||||
}
|
||||
fields += tuiFormField(lbl, m.cfgInputs[i])
|
||||
}
|
||||
b.WriteString(tuiWizardCard("Hostnames & Identity", "", fields))
|
||||
if m.cfgErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.cfgErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "configure", "esc", "back"))
|
||||
|
||||
case egWorking:
|
||||
b.WriteString(tuiHint.Render("Configuring Cloudflare Tunnel + Access edge…") + "\n\n")
|
||||
for _, s := range m.prog {
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n")
|
||||
}
|
||||
if m.working != "" {
|
||||
b.WriteString(" " + tuiIconSpin + " " + m.working + "\n")
|
||||
}
|
||||
if len(m.prog) == 0 && m.working == "" {
|
||||
b.WriteString(tuiHint.Render("Starting…") + "\n")
|
||||
}
|
||||
|
||||
case egDone:
|
||||
b.WriteString(tuiSuccessBanner("Cloudflare edge configured.") + "\n\n")
|
||||
var box string
|
||||
func (m *edgeModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiOK.Render("✓ Cloudflare edge configured.") + "\n\n")
|
||||
var card strings.Builder
|
||||
if m.result != nil {
|
||||
box = tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n"
|
||||
card.WriteString(tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n")
|
||||
if len(m.result.RoutedHostnames) > 0 {
|
||||
box += tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n"
|
||||
card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n")
|
||||
}
|
||||
if m.result.ConfigPath != "" {
|
||||
box += tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n"
|
||||
card.WriteString(tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box) + "\n\n")
|
||||
b.WriteString(tuiOK.Render("✓") + " Felis config, Kubernetes Secret, API rollout and cloudflared service updated.\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter/esc", "back"))
|
||||
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
||||
b.WriteString(tuiHint.Render("ℹ Felis config, Kubernetes Secret, API rollout and the cloudflared service were all updated.") + "\n")
|
||||
b.WriteString("\n" + tuiAction("enter/esc", "continue"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
case egError:
|
||||
b.WriteString(tuiErrorBanner("Edge setup failed.") + "\n\n")
|
||||
func (m *edgeModel) errorView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiErr.Render("✗ Edge setup failed.") + "\n\n")
|
||||
if len(m.prog) > 0 {
|
||||
b.WriteString(tuiHint.Render("Completed before failure:") + "\n")
|
||||
b.WriteString(tuiHint.Render("Completed before the failure:") + "\n")
|
||||
for _, s := range m.prog {
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n")
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + tuiHint.Render(s) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "retry", "esc", "back"))
|
||||
}
|
||||
b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
return m.handleIntroKey(msg)
|
||||
case egAuth:
|
||||
return m.handleAuthKey(msg)
|
||||
case egConfig:
|
||||
return m.handleCfgKey(msg)
|
||||
case egDone, egError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
return m, goBack()
|
||||
case "enter":
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
if m.step == egError {
|
||||
m.step = egConfig
|
||||
m.lastErr = nil
|
||||
m.prog = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
default:
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
@@ -305,184 +452,41 @@ func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "l", "L":
|
||||
if m.cloudflaredPath != "" && !m.certExists {
|
||||
nm, cmd := m.startLogin()
|
||||
return nm, cmd
|
||||
return m.startLogin()
|
||||
}
|
||||
case "enter":
|
||||
if m.cloudflaredPath != "" && m.certExists {
|
||||
m.step = egAuth
|
||||
return m, m.enterAuth()
|
||||
m.step = egForm
|
||||
m.form = m.build()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleAuthKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.step = egIntro
|
||||
m.authErr = ""
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.authFocus = (m.authFocus + 1) % 2
|
||||
return m, m.focusAuthInput(m.authFocus)
|
||||
case "shift+tab", "up":
|
||||
m.authFocus = (m.authFocus + 1) % 2
|
||||
return m, m.focusAuthInput(m.authFocus)
|
||||
case "enter":
|
||||
return m.submitAuth()
|
||||
}
|
||||
return m, m.updateAuthInputs(msg)
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleCfgKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
m.step = egAuth
|
||||
m.cfgErr = ""
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.cfgFocus = (m.cfgFocus + 1) % 5
|
||||
return m, m.focusCfgInput(m.cfgFocus)
|
||||
case "shift+tab", "up":
|
||||
m.cfgFocus = (m.cfgFocus + 4) % 5
|
||||
return m, m.focusCfgInput(m.cfgFocus)
|
||||
case "enter":
|
||||
return m.submitConfig()
|
||||
}
|
||||
return m, m.updateCfgInputs(msg)
|
||||
}
|
||||
|
||||
func (m *edgeModel) enterAuth() tea.Cmd {
|
||||
token := tuiInput("cfat_…", 200, true)
|
||||
account := tuiInput("32-char account ID", 64, false)
|
||||
m.authInputs = []textinput.Model{token, account}
|
||||
m.authFocus = 0
|
||||
return m.focusAuthInput(0)
|
||||
}
|
||||
|
||||
func (m *edgeModel) focusAuthInput(i int) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
for j := range m.authInputs {
|
||||
if j == i {
|
||||
cmd = m.authInputs[j].Focus()
|
||||
} else {
|
||||
m.authInputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *edgeModel) updateAuthInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.authInputs))
|
||||
for i := range m.authInputs {
|
||||
m.authInputs[i], cmds[i] = m.authInputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *edgeModel) submitAuth() (tea.Model, tea.Cmd) {
|
||||
token := strings.TrimSpace(m.authInputs[0].Value())
|
||||
account := strings.TrimSpace(m.authInputs[1].Value())
|
||||
if token == "" {
|
||||
m.authErr = "a Cloudflare API token is required"
|
||||
return m, nil
|
||||
}
|
||||
if account == "" {
|
||||
m.authErr = "the Cloudflare account ID is required"
|
||||
return m, nil
|
||||
}
|
||||
if !isHex32(account) {
|
||||
if strings.HasPrefix(account, "cfat_") {
|
||||
m.authErr = "that looks like an API token — the Account ID is a 32-char hex string"
|
||||
} else {
|
||||
m.authErr = "the Account ID must be 32 hex characters"
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.authErr = ""
|
||||
return m, func() tea.Msg { return egAuthDoneMsg{token: token, account: account} }
|
||||
}
|
||||
|
||||
func (m *edgeModel) enterConfig() tea.Cmd {
|
||||
identity := tuiInput("[email protected] or @your-domain", 254, false)
|
||||
panelHost := tuiInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false)
|
||||
panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname))
|
||||
adminHost := tuiInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false)
|
||||
adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname))
|
||||
tunnel := tuiInput(defaultTunnelName, 64, false)
|
||||
tunnel.SetValue(defaultTunnelName)
|
||||
cfgPath := tuiInput(defaultTunnelConfigPath, 256, false)
|
||||
cfgPath.SetValue(defaultTunnelConfigPath)
|
||||
m.cfgInputs = []textinput.Model{identity, panelHost, adminHost, tunnel, cfgPath}
|
||||
m.cfgFocus = 0
|
||||
return m.focusCfgInput(0)
|
||||
}
|
||||
|
||||
func (m *edgeModel) focusCfgInput(i int) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
for j := range m.cfgInputs {
|
||||
if j == i {
|
||||
cmd = m.cfgInputs[j].Focus()
|
||||
} else {
|
||||
m.cfgInputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *edgeModel) updateCfgInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.cfgInputs))
|
||||
for i := range m.cfgInputs {
|
||||
m.cfgInputs[i], cmds[i] = m.cfgInputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
identity := strings.TrimSpace(m.cfgInputs[0].Value())
|
||||
panelHost := normalizeEdgeHostname(m.cfgInputs[1].Value())
|
||||
adminHost := normalizeEdgeHostname(m.cfgInputs[2].Value())
|
||||
tunnel := strings.TrimSpace(m.cfgInputs[3].Value())
|
||||
cfgPath := strings.TrimSpace(m.cfgInputs[4].Value())
|
||||
|
||||
if identity == "" {
|
||||
m.cfgErr = "enter who Access should admit"
|
||||
m.cfgFocus = 0
|
||||
return m, nil
|
||||
}
|
||||
if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" {
|
||||
m.cfgErr = "enter a domain after the @, e.g. @your-domain"
|
||||
m.cfgFocus = 0
|
||||
return m, nil
|
||||
}
|
||||
if err := validateEdgeHostname("player console", panelHost, false); err != nil {
|
||||
m.cfgErr = err.Error()
|
||||
m.cfgFocus = 1
|
||||
return m, nil
|
||||
}
|
||||
if err := validateEdgeHostname("admin console", adminHost, true); err != nil {
|
||||
m.cfgErr = err.Error()
|
||||
m.cfgFocus = 2
|
||||
return m, nil
|
||||
}
|
||||
if panelHost != "" && strings.EqualFold(panelHost, adminHost) {
|
||||
m.cfgErr = "player console and admin console hostnames must be different"
|
||||
m.cfgFocus = 2
|
||||
return m, nil
|
||||
}
|
||||
// startSetup reads the form-bound fields (already validated by huh), normalizes
|
||||
// the hostnames, applies the tunnel/config defaults, and kicks off the live
|
||||
// Cloudflare run. It is shared by the form-completed path and the error retry, so
|
||||
// a retry re-runs against the same inputs without a re-entry.
|
||||
func (m *edgeModel) startSetup() tea.Cmd {
|
||||
m.panelHost = normalizeEdgeHostname(m.panelHost)
|
||||
m.adminHost = normalizeEdgeHostname(m.adminHost)
|
||||
identity := strings.TrimSpace(m.identity)
|
||||
tunnel := strings.TrimSpace(m.tunnelName)
|
||||
if tunnel == "" {
|
||||
tunnel = defaultTunnelName
|
||||
}
|
||||
cfgPath := strings.TrimSpace(m.cfgPath)
|
||||
if cfgPath == "" {
|
||||
cfgPath = defaultTunnelConfigPath
|
||||
}
|
||||
m.tunnelName, m.cfgPath = tunnel, cfgPath
|
||||
|
||||
m.panelSet = panelHost
|
||||
m.adminSet = adminHost
|
||||
m.cfgErr = ""
|
||||
m.panelSet, m.adminSet = m.panelHost, m.adminHost
|
||||
m.prog = nil
|
||||
m.lastErr = nil
|
||||
m.step = egWorking
|
||||
m.working = "Starting…"
|
||||
m.working = "Configuring Cloudflare Tunnel + Access edge…"
|
||||
|
||||
var id cfsetup.AccessIdentity
|
||||
if strings.HasPrefix(identity, "@") {
|
||||
@@ -497,8 +501,8 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
AccountID: m.authAccount,
|
||||
}
|
||||
p := cfsetup.Params{
|
||||
PanelHostname: panelHost,
|
||||
AdminHostname: adminHost,
|
||||
PanelHostname: m.panelHost,
|
||||
AdminHostname: m.adminHost,
|
||||
PanelOrigin: localPanelOrigin(),
|
||||
TunnelName: tunnel,
|
||||
ConfigPath: cfgPath,
|
||||
@@ -506,7 +510,7 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
Pre: cfsetup.DetectPreconditions(m.authToken),
|
||||
}
|
||||
|
||||
return m, m.runEdgeSetup(runner, p)
|
||||
return tea.Batch(m.sp.Tick, m.runEdgeSetup(runner, p))
|
||||
}
|
||||
|
||||
func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cmd {
|
||||
@@ -517,15 +521,15 @@ func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cm
|
||||
return func() tea.Msg {
|
||||
result, err := cfsetup.Setup(context.Background(), runner, p)
|
||||
if err != nil {
|
||||
return egSetupDoneMsg{err: err}
|
||||
return egSetupDoneMsg{err: err, progress: progress}
|
||||
}
|
||||
progress = append(progress, "Applying Felis config and starting cloudflared…")
|
||||
if err := applyCloudflareEdge(context.Background(), result, p.PanelHostname, p.AdminHostname, m.cloudflaredPath); err != nil {
|
||||
return egSetupDoneMsg{err: err}
|
||||
return egSetupDoneMsg{err: err, progress: progress}
|
||||
}
|
||||
progress = append(progress, "Updated Felis config and started cloudflared")
|
||||
result.Progress = progress
|
||||
return egSetupDoneMsg{result: result}
|
||||
return egSetupDoneMsg{result: result, progress: progress}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -39,3 +40,139 @@ func TestWizardViewsFitTerminal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeFormFitsTerminal covers the screen the existing sweep can't reach: the
|
||||
// Cloudflare edge form. The chooser only adopts the edge model when "Cloudflare"
|
||||
// is picked, and its form only opens once cloudflared + the login cert are
|
||||
// present — neither is available in CI — so the connectLocal sweep above never
|
||||
// constructs it. Here we adopt a cloudflared-satisfied edge model through the
|
||||
// root (so the real step-rail chrome is in the budget) and measure BOTH form
|
||||
// groups: credentials (group 1) and the taller hostnames group (group 2, five
|
||||
// inputs). huh clamps each group to a scrolling viewport, but a tall group title
|
||||
// or help footer can still push the composed view past the terminal — which is
|
||||
// exactly the clipping regression this guards.
|
||||
func TestEdgeFormFitsTerminal(t *testing.T) {
|
||||
for _, w := range []int{60, 80, 90} {
|
||||
for _, h := range []int{24, 30, 45} {
|
||||
root := newTestRoot(false, consoleModeSetup, "")
|
||||
root = drive(t, root, tea.WindowSizeMsg{Width: w, Height: h})
|
||||
root.stage = stageConnect
|
||||
|
||||
edge := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com")
|
||||
// Pretend the operator already installed cloudflared + logged in so the
|
||||
// intro lets us open the form without a live cloudflared/cert.
|
||||
edge.cloudflaredPath = "/usr/local/bin/cloudflared"
|
||||
edge.certExists = true
|
||||
root.adopt(edge) // sizes the edge model with the post-rail budget
|
||||
|
||||
// intro → form (group 1: credentials)
|
||||
root = drive(t, root, key(tea.KeyEnter))
|
||||
if edge.step != egForm {
|
||||
t.Fatalf("terminal %dx%d: enter on a ready intro should open the form, step = %v", w, h, edge.step)
|
||||
}
|
||||
// huh clamps every group to a scrolling viewport sized to the budget, so
|
||||
// the rendered height is the same whether the viewport content has been
|
||||
// built yet or not — measuring the freshly-opened frame is a faithful
|
||||
// height check. The group title renders outside the viewport, so it is
|
||||
// present even before content builds, which is how we confirm we are on
|
||||
// the right group.
|
||||
if v := root.View(); !strings.Contains(v, "Step 1 · Credentials") {
|
||||
t.Fatalf("terminal %dx%d: form should open on the credentials group, got:\n%s", w, h, v)
|
||||
}
|
||||
if got := lipgloss.Height(root.View()); got > h {
|
||||
t.Errorf("terminal %dx%d: edge credentials view = %d rows (exceeds height)", w, h, got)
|
||||
}
|
||||
|
||||
// Advance to the taller hostnames group (5 inputs). huh advances groups
|
||||
// natively when the current group has no errors; NextGroup mutates the
|
||||
// form in place, and edge holds the same form pointer the root renders.
|
||||
edge.form.NextGroup()
|
||||
if v := root.View(); !strings.Contains(v, "Step 2 · Hostnames & identity") {
|
||||
t.Fatalf("terminal %dx%d: NextGroup should reveal the hostnames group, got:\n%s", w, h, v)
|
||||
}
|
||||
if got := lipgloss.Height(root.View()); got > h {
|
||||
t.Errorf("terminal %dx%d: edge hostnames view = %d rows (exceeds height)", w, h, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeValidators locks the input-validation logic the edge form relies on —
|
||||
// the part that decides whether a friend's real run is accepted or rejected
|
||||
// before any Cloudflare call. These are the validators wired into the huh fields;
|
||||
// testing them directly is honest coverage that does not depend on driving huh.
|
||||
func TestEdgeValidators(t *testing.T) {
|
||||
accountCases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0123456789abcdef0123456789abcdef", true},
|
||||
{"0123456789ABCDEF0123456789abcdef", true},
|
||||
{"cfat_looks_like_a_token", false}, // token pasted into the account field
|
||||
{"too-short", false},
|
||||
{"0123456789abcdef0123456789abcde", false}, // 31 chars
|
||||
}
|
||||
for _, c := range accountCases {
|
||||
if err := validateAccountID(c.in); (err == nil) != c.ok {
|
||||
t.Errorf("validateAccountID(%q): ok=%v, err=%v", c.in, c.ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
identityCases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
}{
|
||||
{"", false},
|
||||
{"@", false}, // bare @ with no domain
|
||||
{"[email protected]", true},
|
||||
{"@your-domain.com", true},
|
||||
}
|
||||
for _, c := range identityCases {
|
||||
if err := validateAccessIdentity(c.in); (err == nil) != c.ok {
|
||||
t.Errorf("validateAccessIdentity(%q): ok=%v, err=%v", c.in, c.ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeFormExitKeys locks the form's exit keys, which huh cannot disambiguate
|
||||
// on its own: it collapses esc and ctrl+c into a single StateAborted, so the edge
|
||||
// model must intercept them before delegating. esc steps back to the intro/status
|
||||
// screen (matching reverseProxyModel's esc=back); ctrl+c quits like every sibling
|
||||
// screen. Without this, ctrl+c from the form would fall through huh's abort path
|
||||
// and return to the intro instead of quitting, and a failed setup could strand the
|
||||
// user on the form. The keys only matter live, so the contract lives here.
|
||||
func TestEdgeFormExitKeys(t *testing.T) {
|
||||
open := func(t *testing.T) *edgeModel {
|
||||
t.Helper()
|
||||
e := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com")
|
||||
e.cloudflaredPath = "/usr/local/bin/cloudflared"
|
||||
e.certExists = true
|
||||
next, _ := e.Update(key(tea.KeyEnter)) // ready intro → form
|
||||
em, ok := next.(*edgeModel)
|
||||
if !ok {
|
||||
t.Fatalf("intro enter returned %T, want *edgeModel", next)
|
||||
}
|
||||
if em.step != egForm {
|
||||
t.Fatalf("intro enter should open the form, step = %v", em.step)
|
||||
}
|
||||
return em
|
||||
}
|
||||
|
||||
// esc steps back to the intro/status screen — not quit, not stuck on the form.
|
||||
e := open(t)
|
||||
next, _ := e.Update(key(tea.KeyEsc))
|
||||
if got := next.(*edgeModel).step; got != egIntro {
|
||||
t.Fatalf("esc on the edge form should return to the intro, step = %v", got)
|
||||
}
|
||||
|
||||
// ctrl+c quits, like every sibling screen.
|
||||
e = open(t)
|
||||
_, cmd := e.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
if cmd == nil {
|
||||
t.Fatal("ctrl+c on the edge form should return a command (tea.Quit)")
|
||||
}
|
||||
if msg := cmd(); !isQuit(msg) {
|
||||
t.Fatalf("ctrl+c command = %T, want tea.Quit", msg)
|
||||
}
|
||||
}
|
||||
+136
-3
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
@@ -17,6 +18,14 @@ type sizeable interface {
|
||||
setSize(width, height int)
|
||||
}
|
||||
|
||||
// arrowNavigable is implemented by screens that don't need ←/→ for their own
|
||||
// input (selects, summaries), so the root may repurpose those keys to walk back
|
||||
// through completed steps. Text-input screens omit it and keep the arrows for
|
||||
// cursor movement — that's the "don't fight the input fields" rule.
|
||||
type arrowNavigable interface {
|
||||
arrowNavOK() bool
|
||||
}
|
||||
|
||||
// ---- Connection methods ----
|
||||
|
||||
type connectMethod int
|
||||
@@ -82,12 +91,23 @@ const (
|
||||
stageSummary
|
||||
)
|
||||
|
||||
// setupRailSteps is the one progress rail shared by the whole first-run flow,
|
||||
// spanning both bubbletea programs: the host-bootstrap installer is rail cell 0,
|
||||
// and the post-install wizard owns cells 1–4. Defining it once keeps the two
|
||||
// programs' breadcrumbs identical so the rail reads as a single continuous bar
|
||||
// rather than restarting when the wizard takes over.
|
||||
var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Done"}
|
||||
|
||||
type rootModel struct {
|
||||
ctx context.Context
|
||||
|
||||
screen tea.Model
|
||||
stage wizardStage
|
||||
|
||||
// reviewing is the index of a completed step the operator is looking back at
|
||||
// (read-only), or -1 when the live screen is in front. Driven by ←/→.
|
||||
reviewing int
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
@@ -108,6 +128,7 @@ type rootModel struct {
|
||||
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
||||
rm := &rootModel{
|
||||
ctx: ctx,
|
||||
reviewing: -1,
|
||||
dbURL: dbURL,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
@@ -138,6 +159,15 @@ func (m *rootModel) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Rail navigation claims ←/→ before anything else sees them. While reviewing
|
||||
// it owns every key so nothing leaks into the live screen underneath;
|
||||
// otherwise it only takes ← (to enter review) and lets the rest fall through.
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if handled, model, cmd := m.handleRailKey(key); handled {
|
||||
return model, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
@@ -199,10 +229,91 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// first View — the fix for the rail being clipped off the top of the frame.
|
||||
func (m *rootModel) adopt(s tea.Model) (tea.Model, tea.Cmd) {
|
||||
m.screen = s
|
||||
m.reviewing = -1 // every stage change drops back to the live screen
|
||||
m.pushSize()
|
||||
return m, s.Init()
|
||||
}
|
||||
|
||||
// handleRailKey implements ←/→ navigation of the step rail. While reviewing a
|
||||
// completed step it owns every key (so nothing leaks into the live screen);
|
||||
// otherwise it claims only ← to enter review, and only when the active screen
|
||||
// doesn't need the arrows for its own text input.
|
||||
func (m *rootModel) handleRailKey(k tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
|
||||
if m.reviewing >= 0 {
|
||||
switch k.String() {
|
||||
case "ctrl+c":
|
||||
return true, m, tea.Quit
|
||||
case "left":
|
||||
if m.reviewing > 0 {
|
||||
m.reviewing--
|
||||
}
|
||||
return true, m, nil
|
||||
case "right":
|
||||
m.reviewing++
|
||||
if m.reviewing >= int(m.stage) {
|
||||
m.reviewing = -1 // caught up to the live step
|
||||
}
|
||||
return true, m, nil
|
||||
case "esc", "enter":
|
||||
m.reviewing = -1
|
||||
return true, m, nil
|
||||
default:
|
||||
return true, m, nil // swallow everything else while reviewing
|
||||
}
|
||||
}
|
||||
if k.String() == "left" && m.canEnterReview() {
|
||||
m.reviewing = int(m.stage) - 1
|
||||
return true, m, nil
|
||||
}
|
||||
return false, m, nil
|
||||
}
|
||||
|
||||
// canEnterReview reports whether the live screen will yield ←/→ to the rail.
|
||||
func (m *rootModel) canEnterReview() bool {
|
||||
if m.mode != consoleModeSetup || m.adminExistsAtStart() || m.stage == 0 {
|
||||
return false
|
||||
}
|
||||
n, ok := m.screen.(arrowNavigable)
|
||||
return ok && n.arrowNavOK()
|
||||
}
|
||||
|
||||
// displayStage is the rail position currently shown — the reviewed step when
|
||||
// looking back, otherwise the live stage.
|
||||
func (m *rootModel) displayStage() int {
|
||||
if m.reviewing >= 0 {
|
||||
return m.reviewing
|
||||
}
|
||||
return int(m.stage)
|
||||
}
|
||||
|
||||
// reviewBody renders a read-only recap of an already-completed step. Steps in
|
||||
// this wizard commit as you finish them (the Owner account and its one-time
|
||||
// password are created on submit), so review is deliberately look-only — there
|
||||
// is no re-editing a step you've passed.
|
||||
func (m *rootModel) reviewBody(stage int) string {
|
||||
var b strings.Builder
|
||||
switch wizardStage(stage) {
|
||||
case stagePreflight:
|
||||
b.WriteString(tuiOK.Render("✓ Preflight") + "\n")
|
||||
b.WriteString(tuiHint.Render("Control plane verified before configuration."))
|
||||
case stageOwner:
|
||||
b.WriteString(tuiOK.Render("✓ Owner account") + "\n")
|
||||
if m.result.username != "" {
|
||||
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n")
|
||||
}
|
||||
b.WriteString(tuiHint.Render("Created and recorded. The one-time password was shown on the Owner step."))
|
||||
case stageConnect:
|
||||
b.WriteString(tuiOK.Render("✓ Connection") + "\n")
|
||||
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n")
|
||||
if m.result.panelURL != "" {
|
||||
b.WriteString(tuiLabel.Render("panel ") + m.result.panelURL)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n\n" + tuiHint.Render("read-only · ") + tuiLabel.Render("←/→") +
|
||||
tuiHint.Render(" walk steps · ") + tuiLabel.Render("esc") + tuiHint.Render(" back"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pushSize gives the active screen the area left after the step rail.
|
||||
func (m *rootModel) pushSize() {
|
||||
if m.height == 0 {
|
||||
@@ -227,7 +338,7 @@ func (m *rootModel) chromeHeight() int {
|
||||
if m.mode != consoleModeSetup || m.adminExistsAtStart() {
|
||||
return 0
|
||||
}
|
||||
return lipgloss.Height(m.rail()) + 1
|
||||
return lipgloss.Height(m.railWithHint()) + 1
|
||||
}
|
||||
|
||||
func (m *rootModel) View() string {
|
||||
@@ -235,7 +346,11 @@ func (m *rootModel) View() string {
|
||||
return ""
|
||||
}
|
||||
if m.mode == consoleModeSetup && !m.adminExistsAtStart() {
|
||||
return m.rail() + "\n\n" + m.screen.View()
|
||||
body := m.screen.View()
|
||||
if m.reviewing >= 0 {
|
||||
body = m.reviewBody(m.reviewing)
|
||||
}
|
||||
return m.railWithHint() + "\n\n" + body
|
||||
}
|
||||
return m.screen.View()
|
||||
}
|
||||
@@ -250,7 +365,25 @@ func (m *rootModel) adminExistsAtStart() bool {
|
||||
}
|
||||
|
||||
func (m *rootModel) rail() string {
|
||||
return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage))
|
||||
// Bootstrap is rail cell 0 and is always done by the time the wizard runs (an
|
||||
// open DB is the proof), so the wizard's own stages render starting at cell 1.
|
||||
return tuiStepRail(setupRailSteps, m.displayStage()+1)
|
||||
}
|
||||
|
||||
// railWithHint appends a discoverability hint when ←/→ can walk the rail — while
|
||||
// reviewing, or on a live screen that yields the arrows.
|
||||
func (m *rootModel) railWithHint() string {
|
||||
r := m.rail()
|
||||
switch {
|
||||
case m.reviewing >= 0:
|
||||
// Mid-review both directions move; → eventually returns to the live step.
|
||||
r += tuiRailSep.Render(" ") + tuiRailTodo.Render("←/→ review steps")
|
||||
case m.canEnterReview():
|
||||
// At the live frontier only ← does anything — there's nothing ahead, so
|
||||
// don't advertise → and have it silently no-op.
|
||||
r += tuiRailSep.Render(" ") + tuiRailTodo.Render("← review steps")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// applyConnectResult records the chosen connection outcome onto the result.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -174,6 +175,93 @@ func TestRootBreakGlassQuitsAfterOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
|
||||
|
||||
// TestRootRailReviewNavigation locks the ←/→ rail-walk added for ergonomics:
|
||||
// from a yielding screen ← steps back through completed stages read-only,
|
||||
// → / esc return to the live screen, and ← is ignored on text-input screens
|
||||
// (which need the arrow for their cursor). A screenshot can't verify this — the
|
||||
// keys only matter live — so the contract lives here.
|
||||
func TestRootRailReviewNavigation(t *testing.T) {
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
|
||||
// On the Owner screen (text inputs) ← must NOT hijack the arrow: it stays
|
||||
// with the field, so we remain on the live screen.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("← on the owner (text-input) screen entered review (%d); arrows belong to the field", m.reviewing)
|
||||
}
|
||||
|
||||
// Advance to the Connection chooser (a select — it yields ←/→).
|
||||
m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"})
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing)
|
||||
}
|
||||
|
||||
// ← walks back to Owner (read-only recap), then Preflight, then clamps.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stageOwner) {
|
||||
t.Fatalf("first ← = stage %d, want stageOwner %d", m.reviewing, stageOwner)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "Owner account") || !strings.Contains(v, "username") {
|
||||
t.Fatalf("owner review body missing recap, got:\n%s", v)
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stagePreflight) {
|
||||
t.Fatalf("second ← = stage %d, want stagePreflight %d", m.reviewing, stagePreflight)
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing != int(stagePreflight) {
|
||||
t.Fatalf("← past the first step should clamp, got %d", m.reviewing)
|
||||
}
|
||||
|
||||
// → walks forward; stepping past the last completed step returns to live.
|
||||
m = drive(t, m, key(tea.KeyRight))
|
||||
if m.reviewing != int(stageOwner) {
|
||||
t.Fatalf("→ = stage %d, want stageOwner %d", m.reviewing, stageOwner)
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyRight))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("→ past the last completed step should return live, reviewing = %d", m.reviewing)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "reach the panel") {
|
||||
t.Fatalf("returning live should show the chooser, got:\n%s", v)
|
||||
}
|
||||
|
||||
// esc is an immediate escape hatch back to the live screen.
|
||||
m = drive(t, m, key(tea.KeyLeft))
|
||||
if m.reviewing < 0 {
|
||||
t.Fatalf("← should re-enter review")
|
||||
}
|
||||
m = drive(t, m, key(tea.KeyEsc))
|
||||
if m.reviewing != -1 {
|
||||
t.Fatalf("esc should return to the live screen, reviewing = %d", m.reviewing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupRailSpansBootstrap locks the cross-program progress rail: the
|
||||
// host-bootstrap screen shows Bootstrap as the live step 1, and once the wizard
|
||||
// takes over Bootstrap is carried as a completed (✓) step ahead of the live one.
|
||||
// This is what makes the rail read as one continuous bar across the two separate
|
||||
// bubbletea programs instead of restarting when the wizard launches.
|
||||
func TestSetupRailSpansBootstrap(t *testing.T) {
|
||||
boot := newHostBootstrapModel(context.Background())
|
||||
if v := boot.View(); !strings.Contains(v, "1. Bootstrap") || !strings.Contains(v, "Preflight") {
|
||||
t.Fatalf("bootstrap screen should show the shared rail with Bootstrap as step 1, got:\n%s", v)
|
||||
}
|
||||
|
||||
m := newTestRoot(false, consoleModeSetup, "")
|
||||
m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30})
|
||||
m = drive(t, m, preflightDoneMsg{})
|
||||
if _, ok := m.screen.(*ownerModel); !ok {
|
||||
t.Fatalf("expected owner screen after preflight, got %T", m.screen)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "✓ Bootstrap") {
|
||||
t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
// isQuit reports whether a command's message is tea.Quit's sentinel. tea.Quit
|
||||
// returns an unexported tea.quitMsg, so compare against the documented value
|
||||
// produced by calling tea.Quit itself.
|
||||
|
||||
@@ -14,20 +14,6 @@ var (
|
||||
cBgInput = lipgloss.Color("235") // input field bg
|
||||
cWhite = lipgloss.Color("15")
|
||||
|
||||
// Title bar — inverted primary
|
||||
tuiTitle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cWhite).
|
||||
Background(cPrimary).
|
||||
Padding(0, 2).
|
||||
Width(70)
|
||||
|
||||
// Section header
|
||||
tuiSection = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(cPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
// Card styles
|
||||
tuiCardStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
|
||||
@@ -23,6 +23,10 @@ type summaryModel struct {
|
||||
|
||||
func (m *summaryModel) Init() tea.Cmd { return nil }
|
||||
|
||||
// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps;
|
||||
// the summary takes no text input, so the horizontal arrows are free.
|
||||
func (m *summaryModel) arrowNavOK() bool { return true }
|
||||
|
||||
func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
switch key.String() {
|
||||
|
||||
@@ -22,7 +22,12 @@ func felisTheme() *huh.Theme {
|
||||
f.ErrorMessage = lipgloss.NewStyle().Foreground(cError)
|
||||
|
||||
f.SelectSelector = lipgloss.NewStyle().Foreground(cPrimary).SetString("▸ ")
|
||||
f.Option = lipgloss.NewStyle().Foreground(cWhite)
|
||||
// Unselected rows recede to dim and the active row is bright cyan + bold, so
|
||||
// the list reads at a glance as a picker with one row chosen — not a wall of
|
||||
// equally-lit info text. huh's single-select paints UnselectedOption (not
|
||||
// Option), which ThemeBase leaves bright; overriding it is the key fix.
|
||||
f.Option = lipgloss.NewStyle().Foreground(cDim)
|
||||
f.UnselectedOption = lipgloss.NewStyle().Foreground(cDim)
|
||||
f.SelectedOption = lipgloss.NewStyle().Foreground(cPrimary).Bold(true)
|
||||
f.NextIndicator = lipgloss.NewStyle().Foreground(cAccent).MarginLeft(1).SetString("→")
|
||||
f.PrevIndicator = lipgloss.NewStyle().Foreground(cAccent).MarginRight(1).SetString("←")
|
||||
|
||||
@@ -3,15 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func tuiHeader(title string) string {
|
||||
return tuiTitle.Render("🐾 "+title) + "\n\n"
|
||||
}
|
||||
|
||||
func tuiAction(pairs ...string) string {
|
||||
var parts []string
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
@@ -20,28 +13,10 @@ func tuiAction(pairs ...string) string {
|
||||
return tuiActionBar.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
func tuiFormField(label string, input textinput.Model) string {
|
||||
return fmt.Sprintf("%s\n%s",
|
||||
tuiLabel.Render(label),
|
||||
input.View())
|
||||
}
|
||||
|
||||
func tuiInfo(text string) string {
|
||||
return tuiInfoBox.Render(tuiHint.Render("ℹ " + text))
|
||||
}
|
||||
|
||||
func tuiWizardCard(title, desc, body string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSection.Render(title))
|
||||
if desc != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiHint.Render(desc))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(tuiCardStyle.Render(body))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tuiErrorBanner(msg string) string {
|
||||
return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg))
|
||||
}
|
||||
@@ -50,20 +25,6 @@ func tuiSuccessBanner(msg string) string {
|
||||
return tuiCardFocusedStyle.Render(tuiOK.Render("✓ " + msg))
|
||||
}
|
||||
|
||||
func tuiInput(placeholder string, charLimit int, password bool) textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = placeholder
|
||||
ti.CharLimit = charLimit
|
||||
ti.Width = 44
|
||||
ti.Prompt = ""
|
||||
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(cWhite)
|
||||
if password {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
}
|
||||
return ti
|
||||
}
|
||||
|
||||
func tuiSeparator() string {
|
||||
return tuiHint.Render(strings.Repeat("─", 70))
|
||||
}
|
||||
|
||||
@@ -72,6 +72,28 @@ func (r *ExecRunner) apiBase() string {
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// VerifyAPIToken satisfies the apiTokenVerifier seam Setup probes for: it does a
|
||||
// read-only Cloudflare API call so a bad, expired, wrong-account or
|
||||
// under-permissioned token fails BEFORE any tunnel/DNS/config is created, instead
|
||||
// of surfacing late at CreateAccessApplication with a half-built edge left behind.
|
||||
//
|
||||
// It lists Access apps (per_page=1 — the cheapest authenticated read) against the
|
||||
// exact account and permission Setup will write to, so a green result means the
|
||||
// credential that actually gates the side-effecting calls works. The tunnel and
|
||||
// DNS authenticate via cert.pem, not this token, so the Access read is precisely
|
||||
// the credential worth pre-checking. apiGet surfaces 401/403 with the actionable
|
||||
// permission checklist; this method only adds the cheap pre-flight argument
|
||||
// validation so an empty token/account never reaches the wire.
|
||||
func (r *ExecRunner) VerifyAPIToken(ctx context.Context) error {
|
||||
if strings.TrimSpace(r.APIToken) == "" {
|
||||
return fmt.Errorf("cfsetup: Cloudflare API token is required")
|
||||
}
|
||||
if strings.TrimSpace(r.AccountID) == "" {
|
||||
return fmt.Errorf("cfsetup: Cloudflare account ID is required")
|
||||
}
|
||||
return r.apiGet(ctx, fmt.Sprintf("/accounts/%s/access/apps?per_page=1", r.AccountID), nil)
|
||||
}
|
||||
|
||||
// tunnelIDRE extracts the UUID cloudflared prints when a tunnel is created or
|
||||
// already exists.
|
||||
var tunnelIDRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package cfsetup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These exercise ExecRunner.VerifyAPIToken directly against an httptest server.
|
||||
// This is the load-bearing coverage for "is the CF path usable": Setup only runs
|
||||
// the pre-flight token check when the runner satisfies apiTokenVerifier, and the
|
||||
// existing TestSetupVerifiesAPITokenBeforeCloudflareMutations only proves a *fake*
|
||||
// runner is consulted — it says nothing about whether the production ExecRunner
|
||||
// actually verifies anything. Without these, the token never gets checked before
|
||||
// the tunnel and DNS are created on a real account.
|
||||
|
||||
// TestExecRunnerVerifyAPITokenHitsAccessApps confirms the happy path probes the
|
||||
// exact account + Access-read permission Setup needs, with the Bearer token, and
|
||||
// returns nil when Cloudflare answers success.
|
||||
func TestExecRunnerVerifyAPITokenHitsAccessApps(t *testing.T) {
|
||||
const account = "0123456789abcdef0123456789abcdef"
|
||||
var gotPath, gotAuth, gotQuery string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotQuery = r.URL.RawQuery
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := &ExecRunner{APIToken: "cfat_secret", AccountID: account, APIBase: srv.URL}
|
||||
if err := r.VerifyAPIToken(context.Background()); err != nil {
|
||||
t.Fatalf("VerifyAPIToken on a good token = %v, want nil", err)
|
||||
}
|
||||
if want := "/accounts/" + account + "/access/apps"; gotPath != want {
|
||||
t.Fatalf("verify hit path %q, want %q", gotPath, want)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "per_page=1") {
|
||||
t.Fatalf("verify query = %q, want it to request a single page (per_page=1)", gotQuery)
|
||||
}
|
||||
if gotAuth != "Bearer cfat_secret" {
|
||||
t.Fatalf("verify Authorization = %q, want Bearer token", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecRunnerVerifyAPITokenSurfaces401 locks that an invalid/expired token is
|
||||
// reported with the actionable permission checklist apiGet renders — this is the
|
||||
// message the operator sees BEFORE anything is created, which is the whole point.
|
||||
func TestExecRunnerVerifyAPITokenSurfaces401(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := &ExecRunner{APIToken: "bad", AccountID: "0123456789abcdef0123456789abcdef", APIBase: srv.URL}
|
||||
err := r.VerifyAPIToken(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("VerifyAPIToken on a 401 = nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "Bearer API Token") {
|
||||
t.Fatalf("401 error should carry the actionable checklist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecRunnerVerifyAPITokenSurfaces403 locks that a wrong-account or
|
||||
// under-permissioned token names the account in the guidance.
|
||||
func TestExecRunnerVerifyAPITokenSurfaces403(t *testing.T) {
|
||||
const account = "ffffffffffffffffffffffffffffffff"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"success":false,"errors":[{"code":9109,"message":"Unauthorized to access requested resource"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := &ExecRunner{APIToken: "scoped-wrong", AccountID: account, APIBase: srv.URL}
|
||||
err := r.VerifyAPIToken(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("VerifyAPIToken on a 403 = nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), account) {
|
||||
t.Fatalf("403 error should name the account, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecRunnerVerifyAPITokenPreflight confirms empty credentials fail without a
|
||||
// network call — the http handler would panic the test if it were reached.
|
||||
func TestExecRunnerVerifyAPITokenPreflight(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("VerifyAPIToken must not hit the wire when credentials are empty")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cases := []struct{ token, account string }{
|
||||
{"", "0123456789abcdef0123456789abcdef"},
|
||||
{"cfat_x", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := &ExecRunner{APIToken: c.token, AccountID: c.account, APIBase: srv.URL}
|
||||
if err := r.VerifyAPIToken(context.Background()); err == nil {
|
||||
t.Fatalf("VerifyAPIToken(token=%q account=%q) = nil, want a pre-flight error", c.token, c.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecRunnerSatisfiesAPITokenVerifier is a compile-time-ish guard that the
|
||||
// production runner actually implements the seam Setup probes for. If someone
|
||||
// renames or changes the method signature, Setup would silently skip the check
|
||||
// again (the bug this whole effort fixes); this fails loudly instead.
|
||||
func TestExecRunnerSatisfiesAPITokenVerifier(t *testing.T) {
|
||||
var r Runner = &ExecRunner{}
|
||||
if _, ok := r.(apiTokenVerifier); !ok {
|
||||
t.Fatal("ExecRunner must implement apiTokenVerifier so Setup verifies the token before side effects")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user