Configure connection and storage before creating or resuming the one-time Owner login. Remove Minecraft prerequisites from setup and preserve established login credentials. Let staff preview and confirm roles from configured authentication sources using the existing account-link storage and game UUID mapping. Retain in-game code proof for players, add client-version and lobby guidance, and support NodePort passkey origins.
343 lines
14 KiB
Go
343 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/config"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// These tests exercise the root wizard's state machine directly by injecting the
|
|
// inter-screen messages into rootModel.Update. They bypass all real I/O (DB,
|
|
// migrations, panel probe, provisioning) — the screens emit those messages via
|
|
// commands we don't run — so the only thing under test is the orchestration:
|
|
// which screen the root advances to, and what it records on the result. This is
|
|
// the part the dashboard→wizard refactor actually put at risk, and it needs no
|
|
// root, k3s, or database.
|
|
|
|
func drive(t *testing.T, m *rootModel, msg tea.Msg) *rootModel {
|
|
t.Helper()
|
|
next, _ := m.Update(msg)
|
|
rm, ok := next.(*rootModel)
|
|
if !ok {
|
|
t.Fatalf("Update returned %T, want *rootModel", next)
|
|
}
|
|
return rm
|
|
}
|
|
|
|
func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootModel {
|
|
return newRootModel(
|
|
context.Background(),
|
|
&fakeOwnerStore{},
|
|
config.DatabaseConfig{URL: "postgres://localhost/felis"},
|
|
"felis.example.com",
|
|
"admin.felis.example.com",
|
|
"panel.felis.example.com",
|
|
accessAud,
|
|
"minecraft",
|
|
"root",
|
|
adminExists,
|
|
mode,
|
|
)
|
|
}
|
|
|
|
func TestRootSetupHappyPath(t *testing.T) {
|
|
m := newTestRoot(false, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
if m.stage != stageConnect {
|
|
t.Fatalf("stage = %v, want Connection", m.stage)
|
|
}
|
|
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", adminHostname: "new-admin.felis.example.com", guide: "caddy…"})
|
|
if m.stage != stageStorage {
|
|
t.Fatalf("stage = %v, want Storage", m.stage)
|
|
}
|
|
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
|
|
owner, ok := m.screen.(*setupOwnerModel)
|
|
if !ok || owner.panelURL != "https://new-admin.felis.example.com" {
|
|
t.Fatalf("owner setup = %#v", m.screen)
|
|
}
|
|
if m.result.provisioned {
|
|
t.Fatal("Owner must not be minted before configuration")
|
|
}
|
|
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://new-admin.felis.example.com/setup?token=t0ken"})
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok || sum.setupTokenURL != m.result.setupTokenURL || sum.panelURL != "https://new-admin.felis.example.com" || sum.storageLabel != "s3://bucket" {
|
|
t.Fatalf("summary = %#v", m.screen)
|
|
}
|
|
if !strings.Contains(sum.View(), "Finish Owner login") || !strings.Contains(sum.View(), "Minecraft can be linked later") {
|
|
t.Fatal(sum.View())
|
|
}
|
|
}
|
|
|
|
func TestRootSetupLocalSummary(t *testing.T) {
|
|
m := newTestRoot(false, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
|
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
|
|
m = drive(t, m, ownerResultMsg{username: "owner"})
|
|
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok {
|
|
t.Fatalf("screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
if !sum.localHint {
|
|
t.Fatalf("local connection summary should set localHint")
|
|
}
|
|
// Local never points at the public hostname.
|
|
if sum.panelURL == "https://panel.felis.example.com" {
|
|
t.Fatalf("local summary panelURL should be the local origin, got %q", sum.panelURL)
|
|
}
|
|
}
|
|
|
|
// TestRootReconfigureConnectSkipsStorage locks the flow guard: from the finished
|
|
// summary, "change connection" re-enters only the connection chooser and returns
|
|
// straight to the summary — storage was already configured, so the operator is not
|
|
// dragged back through it, and the earlier storage recap is preserved.
|
|
func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
|
|
m := newTestRoot(false, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
|
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
|
|
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"})
|
|
if _, ok := m.screen.(*summaryModel); !ok {
|
|
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
|
|
// "change connection" re-enters the connection chooser.
|
|
m = drive(t, m, reconfigureConnectMsg{})
|
|
if m.stage != stageConnect {
|
|
t.Fatalf("reconfigure stage = %v, want stageConnect", m.stage)
|
|
}
|
|
if _, ok := m.screen.(*connectChooserModel); !ok {
|
|
t.Fatalf("reconfigure screen = %T, want *connectChooserModel", m.screen)
|
|
}
|
|
|
|
// Completing it returns straight to the summary — NOT the storage chooser —
|
|
// with the original storage recap intact.
|
|
m = drive(t, m, connectResultMsg{method: connectReverseProxy, panelHostname: "panel.felis.example.com", guide: "caddy…"})
|
|
if _, ok := m.screen.(*setupOwnerModel); !ok {
|
|
t.Fatalf("pending link should refresh, screen = %T", m.screen)
|
|
}
|
|
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com/setup?token=fresh"})
|
|
if m.stage != stageSummary {
|
|
t.Fatalf("after reconfigure connect, stage = %v, want stageSummary", m.stage)
|
|
}
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok {
|
|
t.Fatalf("after reconfigure connect, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
if sum.storageLabel != "s3://bucket" {
|
|
t.Fatalf("reconfigure summary storageLabel = %q, want preserved %q", sum.storageLabel, "s3://bucket")
|
|
}
|
|
if m.result.connectMethod != connectReverseProxy {
|
|
t.Fatalf("reconfigure did not update connectMethod: %v", m.result.connectMethod)
|
|
}
|
|
}
|
|
|
|
// TestRootReconfigureStorageReEntersChooser locks the post-install "change storage"
|
|
// path: from the finished summary it re-enters the storage chooser (not the
|
|
// connection one) and returns to the summary carrying the new storage recap.
|
|
func TestRootReconfigureStorageReEntersChooser(t *testing.T) {
|
|
m := newTestRoot(false, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
|
|
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
|
|
m = drive(t, m, ownerResultMsg{username: "owner"})
|
|
if _, ok := m.screen.(*summaryModel); !ok {
|
|
t.Fatalf("after first run, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
|
|
// "change storage" re-enters the storage chooser.
|
|
m = drive(t, m, reconfigureStorageMsg{})
|
|
if m.stage != stageStorage {
|
|
t.Fatalf("reconfigure-storage stage = %v, want stageStorage", m.stage)
|
|
}
|
|
if _, ok := m.screen.(*storageChooserModel); !ok {
|
|
t.Fatalf("reconfigure-storage screen = %T, want *storageChooserModel", m.screen)
|
|
}
|
|
|
|
// Completing it returns to the summary with the updated storage recap.
|
|
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://newbucket"})
|
|
if m.stage != stageSummary {
|
|
t.Fatalf("after reconfigure-storage, stage = %v, want stageSummary", m.stage)
|
|
}
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok {
|
|
t.Fatalf("after reconfigure-storage, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
if sum.storageLabel != "s3://newbucket" {
|
|
t.Fatalf("summary storageLabel = %q, want updated %q", sum.storageLabel, "s3://newbucket")
|
|
}
|
|
}
|
|
|
|
// TestRootReconfigureSMTP locks the post-install "configure email" path: from
|
|
// the re-run status screen it opens the SMTP form, and finishing it lands back
|
|
// on the status screen (not the first-run summary, which would drop the
|
|
// alreadySetUp framing).
|
|
func TestRootReconfigureSMTP(t *testing.T) {
|
|
m := newTestRoot(true, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, setupReadyMsg{username: "owner"})
|
|
if _, ok := m.screen.(*summaryModel); !ok {
|
|
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
|
|
m = drive(t, m, reconfigureSMTPMsg{})
|
|
if _, ok := m.screen.(*smtpModel); !ok {
|
|
t.Fatalf("reconfigure-smtp screen = %T, want *smtpModel", m.screen)
|
|
}
|
|
|
|
m = drive(t, m, smtpResultMsg{configured: true, detail: "smtp.example.net:587 · from [email protected]"})
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok {
|
|
t.Fatalf("after reconfigure-smtp, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
if !sum.alreadySetUp {
|
|
t.Fatalf("after reconfigure-smtp, summary should still be the alreadySetUp status screen")
|
|
}
|
|
}
|
|
|
|
// TestRootReconfigureStorageKeepsStatusFraming locks the same rule for the
|
|
// "change storage" path: on a re-run, completing it must land back on the
|
|
// alreadySetUp status framing (with the updated recap), not "Setup complete."
|
|
func TestRootReconfigureStorageKeepsStatusFraming(t *testing.T) {
|
|
m := newTestRoot(true, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, setupReadyMsg{username: "owner"})
|
|
if _, ok := m.screen.(*summaryModel); !ok {
|
|
t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
|
|
m = drive(t, m, reconfigureStorageMsg{})
|
|
if _, ok := m.screen.(*storageChooserModel); !ok {
|
|
t.Fatalf("reconfigure-storage screen = %T, want *storageChooserModel", m.screen)
|
|
}
|
|
|
|
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"})
|
|
sum, ok := m.screen.(*summaryModel)
|
|
if !ok {
|
|
t.Fatalf("after reconfigure-storage, screen = %T, want *summaryModel", m.screen)
|
|
}
|
|
if !sum.alreadySetUp {
|
|
t.Fatalf("after reconfigure-storage, summary should keep the alreadySetUp framing")
|
|
}
|
|
if sum.storageLabel != "local disk · /var/lib/felis/uploads" {
|
|
t.Fatalf("storageLabel = %q, want the updated recap", sum.storageLabel)
|
|
}
|
|
}
|
|
|
|
func TestRootRerunLandsOnStatus(t *testing.T) {
|
|
m := newTestRoot(true, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
if _, ok := m.screen.(*setupOwnerModel); !ok {
|
|
t.Fatalf("screen = %T", m.screen)
|
|
}
|
|
m = drive(t, m, setupReadyMsg{username: "owner"})
|
|
if m.stage != stageSummary || !m.result.alreadySetUp || m.result.provisioned {
|
|
t.Fatalf("result = %+v", m.result)
|
|
}
|
|
}
|
|
|
|
func TestRootRerunResumesUnfinishedLogin(t *testing.T) {
|
|
m := newTestRoot(true, consoleModeSetup, "")
|
|
m = drive(t, m, preflightDoneMsg{})
|
|
m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://admin.felis.example.com:30443/setup?token=new"})
|
|
sum := m.screen.(*summaryModel)
|
|
if sum.setupTokenURL == "" || !sum.alreadySetUp {
|
|
t.Fatalf("summary = %+v", sum)
|
|
}
|
|
if strings.Contains(m.View(), "Bootstrap") {
|
|
t.Fatal("renewing a login link restarted the deployment rail")
|
|
}
|
|
}
|
|
|
|
func TestRootBreakGlassQuitsAfterOwner(t *testing.T) {
|
|
// Break-glass with a staff account present opens on the operation menu; choosing
|
|
// "provision/reset the Owner" lands on the owner screen, which must quit on
|
|
// completion without entering the connection chooser (that step is setup-only).
|
|
m := newTestRoot(true, consoleModeBreakGlass, "")
|
|
if m.stage != stageMenu {
|
|
t.Fatalf("break-glass initial stage = %v, want stageMenu", m.stage)
|
|
}
|
|
m = drive(t, m, menuChoiceMsg{op: bgProvisionOwner})
|
|
if m.stage != stageOwner {
|
|
t.Fatalf("after the menu choice, stage = %v, want stageOwner", m.stage)
|
|
}
|
|
if _, ok := m.screen.(*ownerModel); !ok {
|
|
t.Fatalf("after the menu choice, screen = %T, want *ownerModel", m.screen)
|
|
}
|
|
|
|
next, cmd := m.Update(ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", mode: "recovery"})
|
|
rm := next.(*rootModel)
|
|
if _, ok := rm.screen.(*connectChooserModel); ok {
|
|
t.Fatalf("break-glass must not enter the connection chooser")
|
|
}
|
|
if cmd == nil {
|
|
t.Fatalf("break-glass owner completion should return a command (tea.Quit)")
|
|
}
|
|
if msg := cmd(); !isQuit(msg) {
|
|
t.Fatalf("break-glass owner completion command = %T, want tea.Quit", msg)
|
|
}
|
|
if !rm.result.provisioned || rm.result.username != "owner" {
|
|
t.Fatalf("break-glass owner result not recorded: %+v", rm.result)
|
|
}
|
|
}
|
|
|
|
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{})
|
|
m = drive(t, m, key(tea.KeyLeft))
|
|
if m.reviewing != int(stagePreflight) || !strings.Contains(m.View(), "Control plane verified") {
|
|
t.Fatal("connection review should return to preflight")
|
|
}
|
|
m = drive(t, m, key(tea.KeyRight))
|
|
if m.reviewing != -1 {
|
|
t.Fatal("right should return to live connection chooser")
|
|
}
|
|
m = drive(t, m, key(tea.KeyLeft))
|
|
m = drive(t, m, key(tea.KeyEsc))
|
|
if m.reviewing != -1 {
|
|
t.Fatal("escape should return to live screen")
|
|
}
|
|
}
|
|
|
|
// 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.(*connectChooserModel); !ok {
|
|
t.Fatalf("expected connection 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.
|
|
func isQuit(msg tea.Msg) bool {
|
|
_, ok := msg.(tea.QuitMsg)
|
|
return ok
|
|
}
|