Files
Felis/cmd/felis/tui_mc_bind_test.go
T

335 lines
11 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"felis.lolicon.best/internal/api"
)
// cmdMsgs runs cmd and the commands of any batch it returns, and collects the
// messages.
func cmdMsgs(cmd tea.Cmd) []tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
if batch, ok := msg.(tea.BatchMsg); ok {
var out []tea.Msg
for _, c := range batch {
out = append(out, cmdMsgs(c)...)
}
return out
}
return []tea.Msg{msg}
}
// settle hands m the messages cmd produces, as the program would, and returns
// them. A huh form draws its fields only once it has handled a message.
func settle(m *mcBindModel, cmd tea.Cmd) []tea.Msg {
msgs := cmdMsgs(cmd)
for _, msg := range msgs {
m.Update(msg)
}
return msgs
}
// openBind is the bind screen as the wizard first shows it.
func openBind(store ownerStore, gameAddr string) *mcBindModel {
m := newMCBindModel(context.Background(), store, "console.example.com", "root", gameAddr)
m.setSize(100, 60)
settle(m, m.Init())
return m
}
// leavesBindScreen reports whether any of msgs ends the Owner step.
func leavesBindScreen(msgs []tea.Msg) bool {
for _, msg := range msgs {
switch msg.(type) {
case ownerResultMsg, ownerSkippedMsg, tea.QuitMsg:
return true
}
}
return false
}
func TestMCBindSaysWhereToJoinAndWhatTheCodeIs(t *testing.T) {
view := openBind(&fakeOwnerStore{}, "10.0.0.5").View()
for _, want := range []string{"join 10.0.0.5", "8 characters", "10 minutes", "/link", "Leave it empty"} {
if !strings.Contains(view, want) {
t.Errorf("bind screen does not say %q:\n%s", want, view)
}
}
if strings.Contains(view, "IP address while") {
t.Errorf("an IP address needs no IP fallback:\n%s", view)
}
named := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "play.example.net:25570")
if got := named.instructions(); !strings.Contains(got, "join play.example.net:25570\n (or this host's IP address") {
t.Errorf("a hostname should come with the IP fallback:\n%s", got)
}
unnamed := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "")
if got := unnamed.instructions(); !strings.Contains(got, "join this server\n") {
t.Errorf("no address should still say where to join:\n%s", got)
}
}
func TestValidLinkCode(t *testing.T) {
for _, tc := range []struct {
in, wantErr string
}{
{"", ""}, // skip
{" ", ""},
{"K7M2QX9P", ""},
{"k7m2qx9p", ""},
{" K7M2-QX9P ", ""},
{"K7M2 QX9P", ""},
{"ABCD12", "a link code is 8 characters; this is 6"},
{"K7M2QX9PZ", "a link code is 8 characters; this is 9"},
{"K7M2QX9O", `never contains 'O'`},
{"K7M2QX90", `never contains '0'`},
{"K7M2QX9É", `never contains 'É'`},
} {
err := validLinkCode(tc.in)
switch {
case tc.wantErr == "" && err != nil:
t.Errorf("validLinkCode(%q) = %v, want nil", tc.in, err)
case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)):
t.Errorf("validLinkCode(%q) = %v, want an error with %q", tc.in, err, tc.wantErr)
}
}
}
// A refused code is the operator's most likely mistake; it must leave them on the
// form with the reason, never end setup.
func TestMCBindRefusedCodeStaysOnTheForm(t *testing.T) {
store := &fakeOwnerStore{redeemErr: api.ErrLinkCodeInvalid}
m := openBind(store, "10.0.0.5")
m.linkCode = "k7m2-qx9p"
_, cmd := m.onFormComplete()
if m.step != mcBindWorking {
t.Fatalf("step = %v after submit, want mcBindWorking", m.step)
}
var result tea.Msg
for _, msg := range cmdMsgs(cmd) {
if r, ok := msg.(mcBindMsg); ok {
result = r
}
}
if result == nil {
t.Fatal("submitting a code did not try to bind it")
}
next, cmd := m.Update(result)
if next != m || m.step != mcBindForm {
t.Fatalf("after a refused code: model %T step %v, want the bind form", next, m.step)
}
if leavesBindScreen(settle(m, cmd)) {
t.Fatal("a refused code ended the Owner step")
}
view := m.View()
for _, want := range []string{"That code was not accepted", "older than 10 minutes", "Type /link", "join 10.0.0.5"} {
if !strings.Contains(view, want) {
t.Errorf("refused-code form does not say %q:\n%s", want, view)
}
}
if m.linkCode != "" {
t.Errorf("the refused code %q is still in the field", m.linkCode)
}
// The next code goes through, and the refusal leaves with it.
store.redeemErr = nil
m.linkCode = "K7M2QX9P"
_, cmd = m.onFormComplete()
for _, msg := range cmdMsgs(cmd) {
if r, ok := msg.(mcBindMsg); ok {
m.Update(r)
}
}
if m.step != mcBindDone {
t.Fatalf("step = %v after a good code, want mcBindDone", m.step)
}
if got := store.redeems[len(store.redeems)-1].code; got != "K7M2QX9P" {
t.Errorf("bound code %q, want K7M2QX9P", got)
}
}
func TestMCBindOtherFailureStaysOnTheForm(t *testing.T) {
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
_, cmd := m.Update(mcBindMsg{err: fmt.Errorf("complete owner setup: %w", errors.New("connection refused"))})
if m.step != mcBindForm || leavesBindScreen(settle(m, cmd)) {
t.Fatalf("a failed bind left the form: step %v", m.step)
}
view := m.View()
for _, want := range []string{"Binding failed: complete owner setup: connection refused", "Nothing was changed"} {
if !strings.Contains(view, want) {
t.Errorf("failed-bind form does not say %q:\n%s", want, view)
}
}
}
// An empty code skips only once the operator confirms; "Enter a code" goes back.
func TestMCBindEmptyCodeSkipsOnlyWhenConfirmed(t *testing.T) {
m := newMCBindModel(context.Background(), &fakeOwnerStore{}, "console.example.com", "root", "10.0.0.5")
m.linkCode, m.skip = " ", false
_, cmd := m.onFormComplete()
if m.step != mcBindForm || leavesBindScreen(cmdMsgs(cmd)) {
t.Fatalf("declining the skip left the form: step %v", m.step)
}
m.linkCode, m.skip = "", true
_, cmd = m.onFormComplete()
skipped := false
for _, msg := range cmdMsgs(cmd) {
if _, ok := msg.(ownerSkippedMsg); ok {
skipped = true
}
}
if !skipped {
t.Fatal("a confirmed skip did not leave the Owner step")
}
}
func TestRootGoesOnWhenTheOwnerIsSkipped(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "")
m.gameAddr = "10.0.0.5"
m = drive(t, m, preflightDoneMsg{})
bind, ok := m.screen.(*mcBindModel)
if !ok || bind.gameAddr != "10.0.0.5" {
t.Fatalf("bind screen = %T without the game address", m.screen)
}
m = drive(t, m, ownerSkippedMsg{})
if m.stage != stageConnect {
t.Fatalf("after skipping, stage = %v, want stageConnect", m.stage)
}
if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("after skipping, screen = %T, want *connectChooserModel", m.screen)
}
if !m.result.ownerSkipped || m.result.provisioned {
t.Fatalf("skip not recorded: %+v", m.result)
}
if got := m.reviewBody(int(stageOwner)); !strings.Contains(got, "Owner account skipped") {
t.Errorf("review of the Owner step = %q", got)
}
m = drive(t, m, connectResultMsg{method: connectLocal})
m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"})
sum, ok := m.screen.(*summaryModel)
if !ok {
t.Fatalf("screen = %T, want *summaryModel", m.screen)
}
view := sum.View()
for _, want := range []string{"Setup finished without an Owner", "not bound", "run sudo felis setup again and join 10.0.0.5"} {
if !strings.Contains(view, want) {
t.Errorf("summary does not say %q:\n%s", want, view)
}
}
for _, unwanted := range []string{"Setup complete", "You won't need this console again"} {
if strings.Contains(view, unwanted) {
t.Errorf("summary without an Owner says %q:\n%s", unwanted, view)
}
}
}
func TestReportSetupResultWithoutAnOwner(t *testing.T) {
const hint = "No Owner is bound yet, so nobody can sign in to the panel."
const bindLast = hint + " To bind one, run\n sudo felis setup\nand join 10.0.0.5 in Minecraft when it asks.\n"
report := func(res breakGlassResult, adminExisted bool) string {
var b bytes.Buffer
reportSetupResult(&b, res, false, adminExisted, "https://10.0.0.5:30443", "10.0.0.5")
return b.String()
}
out := report(breakGlassResult{ownerSkipped: true, connectMethod: connectLocal}, false)
if !strings.Contains(out, "finished without an Owner") || strings.Contains(out, "cancelled") {
t.Errorf("a skipped Owner reads as:\n%s", out)
}
if !strings.HasSuffix(out, bindLast) {
t.Errorf("a skipped Owner does not end on how to bind one:\n%s", out)
}
out = report(breakGlassResult{}, false)
if !strings.Contains(out, "cancelled — no changes made.") || !strings.HasSuffix(out, bindLast) {
t.Errorf("quitting before an Owner is bound reads as:\n%s", out)
}
out = report(breakGlassResult{ownerSkipped: true, connectMethod: connectReverseProxy, connectConfigured: true, reverseProxyGuide: "proxy guide"}, false)
if !strings.Contains(out, "proxy guide") || !strings.HasSuffix(out, bindLast) {
t.Errorf("a skipped Owner with a configured front reads as:\n%s", out)
}
for name, res := range map[string]breakGlassResult{
"re-run": {alreadySetUp: true},
"provisioned": {provisioned: true, username: "mc-uuid-1"},
} {
adminExisted := name == "re-run"
if out := report(res, adminExisted); strings.Contains(out, hint) {
t.Errorf("%s: says no Owner is bound:\n%s", name, out)
}
}
}
func TestSetupGameAddress(t *testing.T) {
for _, tc := range []struct {
root string
port int
want string
}{
{"10.211.55.6.nip.io", 0, "10.211.55.6"},
{"10.211.55.6.nip.io", 25565, "10.211.55.6"},
{"10.211.55.6.sslip.io.", 25570, "10.211.55.6:25570"},
{"play.example.net", 0, "play.example.net"},
{"play.example.net", 25570, "play.example.net:25570"},
{"", 25570, ""},
} {
if got := setupGameAddress(tc.root, tc.port); got != tc.want {
t.Errorf("setupGameAddress(%q, %d) = %q, want %q", tc.root, tc.port, got, tc.want)
}
}
for addr, want := range map[string]bool{
"10.0.0.5": true, "10.0.0.5:25570": true, "play.example.net": false, "play.example.net:25570": false,
} {
if got := gameAddrIsIP(addr); got != want {
t.Errorf("gameAddrIsIP(%q) = %v, want %v", addr, got, want)
}
}
}
// press sends key to m and runs a few rounds of the commands that follow, as the
// program would, so huh can move between fields and groups.
func press(m *mcBindModel, key tea.KeyMsg) {
_, cmd := m.Update(key)
for round := 0; round < 4 && cmd != nil; round++ {
var next []tea.Cmd
for _, msg := range cmdMsgs(cmd) {
if _, c := m.Update(msg); c != nil {
next = append(next, c)
}
}
cmd = tea.Batch(next...)
}
}
// The skip question comes only for an empty code: a typed code goes straight to
// the bind.
func TestMCBindFormAsksBeforeSkipping(t *testing.T) {
m := openBind(&fakeOwnerStore{}, "10.0.0.5")
press(m, tea.KeyMsg{Type: tea.KeyEnter})
if view := m.View(); m.step != mcBindForm || !strings.Contains(view, "Skip the Owner for now?") {
t.Fatalf("enter on an empty code should ask before skipping: step %v\n%s", m.step, view)
}
m = openBind(&fakeOwnerStore{}, "10.0.0.5")
press(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("K7M2QX9P")})
press(m, tea.KeyMsg{Type: tea.KeyEnter})
if m.step == mcBindForm {
t.Fatalf("a typed code did not go to the bind:\n%s", m.View())
}
}