Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/hooks/post-tool-use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ pkg_dir=$(dirname "$rel_path")

# Run go vet on just the touched package — fast (<1s warm).
if ! out=$(go vet "./$pkg_dir/..." 2>&1); then
# "matched no packages" means the package uses build constraints that exclude
# the default build (e.g. //go:build e2e && vm). Not a real vet failure.
case "$out" in
*"matched no packages"*) exit 0 ;;
esac
printf '[openboot post-tool-use] go vet failed for ./%s:\n%s\n' "$pkg_dir" "$out" >&2
exit 2
fi
Expand Down
24 changes: 20 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
.PHONY: test-unit test-e2e test-coverage test-all \
test-vm test-vm-run test-vm-inner test-vm-inner-run \
test-vm test-vm-run test-vm-parallel test-vm-inner test-vm-inner-run \
install-hooks uninstall-hooks

# VM A: install/journey tests that touch real system state (longest-running).
VM_A_TESTS := TestVM_Journey_FirstTimeUser|TestVM_Journey_DryRunIsCompletelySafe|TestVM_Journey_FullSetupConfiguresEverything|TestVM_Interactive_InstallScript
# VM B: all other VM tests — dotfiles, macOS, edge cases, smoke, real-install, sync.
VM_B_TESTS := TestVM_Journey_Dotfiles|TestVM_Journey_MacOS|TestVM_Edge_|TestSmoke_|TestE2E_

BINARY_NAME=openboot
BINARY_PATH=./$(BINARY_NAME)
VERSION ?= dev
Expand Down Expand Up @@ -30,8 +35,8 @@ test-all:

# =============================================================================
# Tart VM e2e — destructive tests run inside a throwaway Tart VM provisioned
# by scripts/vm/run.sh. The 12 files in test/e2e/ run via the `e2e,vm` build
# tag; the VM driver SSHs in and invokes `make test-vm-inner`.
# by scripts/vm/run.sh. Files tagged `e2e,vm` run via `make test-vm-inner`;
# files tagged `e2e && !vm` (auth, snapshot_api) run as L3 on the host.
#
# Requires Apple Silicon + Tart installed locally. See scripts/vm/README.md
# for one-time setup. The relevant targets are defined immediately below
Expand All @@ -46,12 +51,23 @@ test-vm: build
test-vm-run: build
scripts/vm/run.sh "test-vm-inner-run TEST=$(TEST)"

# Developer-facing: runs e2e in two parallel VMs — VM A (system tests) and
# VM B (mock-server tests). Requires ~16 GB RAM and 8 cores free.
# Exit code is non-zero if either VM fails.
test-vm-parallel: build
@OPENBOOT_VM_TEST='$(VM_A_TESTS)' scripts/vm/run.sh test-vm-inner & PID_A=$$!; \
OPENBOOT_VM_TEST='$(VM_B_TESTS)' scripts/vm/run.sh test-vm-inner & PID_B=$$!; \
A_EXIT=0; B_EXIT=0; \
wait $$PID_A || A_EXIT=$$?; \
wait $$PID_B || B_EXIT=$$?; \
[ $$A_EXIT -eq 0 ] && [ $$B_EXIT -eq 0 ]

# In-VM: invoked over SSH by run.sh — not called by developers directly.
test-vm-inner:
go test -v -timeout 60m -tags="e2e,vm" ./test/e2e/...

test-vm-inner-run:
go test -v -timeout 45m -tags="e2e,vm" -run $(TEST) ./test/e2e/...
go test -v -timeout 45m -tags="e2e,vm" -run '$(TEST)' ./test/e2e/...

build:
go build -ldflags="$(LDFLAGS)" -o $(BINARY_PATH) ./cmd/openboot
Expand Down
13 changes: 10 additions & 3 deletions scripts/vm/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ TARGET="$1"

BASE="${OPENBOOT_VM_BASE:-macos-tahoe-base}"
KEEP="${OPENBOOT_VM_KEEP:-0}"
VM_TEST="${OPENBOOT_VM_TEST:-}"
VM="openboot-ephemeral-$$"

# Pre-flight — all checks before we create any disk state.
Expand Down Expand Up @@ -78,12 +79,18 @@ tart exec "$VM" sh -c '/opt/homebrew/bin/mise install go@latest && /opt/homebrew
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
rsync -az --delete \
--exclude='/.git/objects' \
--exclude='/openboot' \
--exclude='/coverage.out' \
--exclude='/coverage.html' \
-e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o IdentitiesOnly=yes -i $SSH_KEY" \
"$REPO_ROOT/" "admin@${VM_IP}:/Users/admin/openboot/"

# Execute target inside VM — use mise exec to put Go on PATH
# Execute target inside VM — use mise exec to put Go on PATH.
# When OPENBOOT_VM_TEST is set, run only the matching tests; single-quote the
# value so that regexp metacharacters (| [ ]) survive the remote shell intact.
if [ -n "$VM_TEST" ]; then
MAKE_CMD="test-vm-inner-run TEST='${VM_TEST}'"
else
MAKE_CMD="${TARGET}"
fi
ssh_exec "$VM_IP" "$SSH_KEY" \
"cd /Users/admin/openboot && CI=true OPENBOOT_IN_VM=1 /opt/homebrew/bin/mise exec go -- make ${TARGET}"
"cd /Users/admin/openboot && CI=true OPENBOOT_IN_VM=1 /opt/homebrew/bin/mise exec go -- make ${MAKE_CMD}"
128 changes: 36 additions & 92 deletions test/e2e/auth_e2e_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//go:build e2e && vm
//go:build e2e && !vm

// Package e2e contains VM-based E2E tests for the login/logout commands,
// Package e2e contains E2E tests for the login/logout commands,
// exercising the full OAuth device flow via the compiled binary against a
// local mock HTTP server.
//
Expand All @@ -12,7 +12,6 @@ package e2e

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand All @@ -37,13 +36,8 @@ import (
// User expectation: after running `openboot login`, a valid auth.json containing
// the token returned by the server should exist at ~/.openboot/auth.json.
func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

// Mock API: /start returns a code; /poll immediately approves.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -65,20 +59,15 @@ func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) {
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Cleanup(srv.Close)

// Inline env overrides guarantee HOME and OPENBOOT_API_URL win over any
// inherited values in the bash subprocess.
cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login",
tmpHome, srv.URL, brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "login")
output := stdout + stderr
t.Logf("login output:\n%s", output)
require.NoError(t, err, "login should succeed against mock server")

// The binary must have written auth.json with the token from our server.
authFile := filepath.Join(tmpHome, ".openboot", "auth.json")
authFile := filepath.Join(home, ".openboot", "auth.json")
data, readErr := os.ReadFile(authFile)
require.NoError(t, readErr, "auth.json should exist after successful login")

Expand All @@ -93,47 +82,29 @@ func TestE2E_Login_SuccessfulOAuthFlow(t *testing.T) {
// "already logged in" when a valid auth.json already exists, without hitting
// the OAuth flow at all.
func TestE2E_Login_AlreadyAuthenticated(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
binary := testutil.BuildTestBinary(t)
home := t.TempDir()
writeTestAuthFile(t, home, "obt_existing", "existinguser")

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
writeTestAuthFile(t, tmpHome, "obt_existing", "existinguser")

cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login",
tmpHome, brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "login")
output := stdout + stderr
t.Logf("login output:\n%s", output)
require.NoError(t, err, "login should succeed when already authenticated")
// loginCmd prints: ui.Success(fmt.Sprintf("Already logged in as %s", stored.Username))
assert.Contains(t, output, "Already logged in as existinguser",
"output should say already logged in with the username")
}

// TestE2E_Login_ServerUnavailable verifies that `openboot login` returns a
// non-zero exit code and a meaningful error when the auth API is unreachable.
func TestE2E_Login_ServerUnavailable(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

// Port 19999 has nothing listening.
cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login",
tmpHome, "http://127.0.0.1:19999", brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, "http://127.0.0.1:19999"), "login")
output := stdout + stderr
t.Logf("login output:\n%s", output)
assert.Error(t, err, "login should fail when server is unreachable")
// loginCmd returns: fmt.Errorf("login failed: %w", err)
assert.Contains(t, output, "login failed",
"error output should say 'login failed', got: %s", output)
}
Expand All @@ -142,13 +113,8 @@ func TestE2E_Login_ServerUnavailable(t *testing.T) {
// "authorization code expired" error from the poll endpoint so the user
// knows to run `openboot login` again — rather than hanging until timeout.
func TestE2E_Login_ExpiredCodeRejected(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand All @@ -166,21 +132,17 @@ func TestE2E_Login_ExpiredCodeRejected(t *testing.T) {
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Cleanup(srv.Close)

cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_API_URL=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s login",
tmpHome, srv.URL, brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "login")
output := stdout + stderr
t.Logf("login output:\n%s", output)
assert.Error(t, err, "login should fail when code is expired")
// pollOnce returns: fmt.Errorf("authorization code expired; please run 'openboot login' again")
assert.Contains(t, output, "expired",
"error output should mention the expired code, got: %s", output)

// auth.json must NOT have been written after a failed login.
authFile := filepath.Join(tmpHome, ".openboot", "auth.json")
authFile := filepath.Join(home, ".openboot", "auth.json")
assert.NoFileExists(t, authFile, "auth.json must not be created after failed login")
}

Expand All @@ -191,49 +153,31 @@ func TestE2E_Login_ExpiredCodeRejected(t *testing.T) {
// TestE2E_Logout_WhenAuthenticated verifies that `openboot logout` removes the
// auth.json token file and confirms the username in its output.
func TestE2E_Logout_WhenAuthenticated(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
binary := testutil.BuildTestBinary(t)
home := t.TempDir()
writeTestAuthFile(t, home, "obt_logout_token", "logoutuser")

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
writeTestAuthFile(t, tmpHome, "obt_logout_token", "logoutuser")

cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s logout",
tmpHome, brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "logout")
output := stdout + stderr
t.Logf("logout output:\n%s", output)
require.NoError(t, err, "logout should succeed")
// logoutCmd prints: ui.Success(fmt.Sprintf("Logged out of %s", stored.Username))
assert.Contains(t, output, "Logged out of logoutuser",
"output should confirm logout with username")

authFile := filepath.Join(tmpHome, ".openboot", "auth.json")
authFile := filepath.Join(home, ".openboot", "auth.json")
assert.NoFileExists(t, authFile, "auth.json should be deleted after logout")
}

// TestE2E_Logout_WhenNotAuthenticated verifies that `openboot logout` handles
// the "not logged in" state gracefully (exit 0, informative message, no crash).
func TestE2E_Logout_WhenNotAuthenticated(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}

vm := testutil.NewMacHost(t)
bin := vmCopyDevBinary(t, vm)
tmpHome := t.TempDir()
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

cmd := fmt.Sprintf(
"HOME=%q OPENBOOT_DISABLE_AUTOUPDATE=1 PATH=%q %s logout",
tmpHome, brewPath, bin,
)
output, err := vm.Run(cmd)
stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "logout")
output := stdout + stderr
t.Logf("logout output:\n%s", output)
require.NoError(t, err, "logout should not fail when not logged in")
// logoutCmd prints: ui.Info("Not logged in.")
assert.Contains(t, output, "Not logged in",
"output should say 'Not logged in', got: %s", output)
}
Expand All @@ -242,10 +186,10 @@ func TestE2E_Logout_WhenNotAuthenticated(t *testing.T) {
// helpers
// =============================================================================

// writeTestAuthFile writes a non-expired auth.json under tmpHome/.openboot/.
func writeTestAuthFile(t *testing.T, tmpHome, token, username string) {
// writeTestAuthFile writes a non-expired auth.json under home/.openboot/.
func writeTestAuthFile(t *testing.T, home, token, username string) {
t.Helper()
authDir := filepath.Join(tmpHome, ".openboot")
authDir := filepath.Join(home, ".openboot")
require.NoError(t, os.MkdirAll(authDir, 0700))

stored := auth.StoredAuth{
Expand Down
44 changes: 44 additions & 0 deletions test/e2e/cli_binary_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//go:build e2e && !vm

// Basic binary behavior tests that require the compiled binary but no system
// state or VM — pure argument validation and version checks.

package e2e

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/openbootdotdev/openboot/testutil"
)

// TestSmoke_VersionMatchesBuild verifies the compiled binary reports a version.
func TestSmoke_VersionMatchesBuild(t *testing.T) {
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""), "version")
output := stdout + stderr
require.NoError(t, err, "version command should succeed")
assert.Contains(t, output, "OpenBoot v", "version output should contain version prefix")
}

// TestE2E_InvalidPreset verifies that an unrecognised preset name causes a
// non-zero exit and an error message mentioning the bad value.
// This is pure argument validation — the binary exits before touching any
// system state, so no VM is needed.
func TestE2E_InvalidPreset(t *testing.T) {
binary := testutil.BuildTestBinary(t)
home := t.TempDir()

stdout, stderr, err := runBinary(t, binary, isolatedEnv(home, ""),
"install", "--preset", "invalid-preset-xyz", "--dry-run", "--silent")
output := stdout + stderr
assert.Error(t, err, "invalid preset should cause command to fail")
assert.True(t,
strings.Contains(output, "invalid") || strings.Contains(output, "unknown") || strings.Contains(output, "error"),
"error output should mention invalid preset, got: %s", output)
}
Loading
Loading