Skip to content

Commit 99fac68

Browse files
fullstackjamclaude
andauthored
test(e2e): add mock-server E2E tests for publish/import path (#30)
Covers three P7 invariants without requiring a real macOS environment: - snapshot --publish --slug X sends PUT with config_slug + Bearer token - snapshot --publish with a saved sync source resolves to PUT (update) - install user/slug --dry-run --silent sends GET with Bearer token Uses httptest.NewServer as the API backend; HOME and OPENBOOT_API_URL are isolated per test so state never leaks between runs. Tag is //go:build e2e (no vm or destructive tags) so CI can run it without a macOS VM. https://claude.ai/code/session_01DfSdBjWHaSGgwzkKRYoa9C Co-authored-by: Claude <noreply@anthropic.com>
1 parent f198569 commit 99fac68

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
"sync"
14+
"testing"
15+
"time"
16+
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
20+
"github.com/openbootdotdev/openboot/testutil"
21+
)
22+
23+
// ── request capture ──────────────────────────────────────────────────────────
24+
25+
type publishImportReq struct {
26+
Method string
27+
Path string
28+
Auth string
29+
Body map[string]interface{}
30+
}
31+
32+
// reqLog records every request received by a mock server.
33+
type reqLog struct {
34+
mu sync.Mutex
35+
reqs []publishImportReq
36+
}
37+
38+
func (l *reqLog) record(r *http.Request) {
39+
pr := publishImportReq{
40+
Method: r.Method,
41+
Path: r.URL.Path,
42+
Auth: r.Header.Get("Authorization"),
43+
}
44+
if r.Body != nil {
45+
_ = json.NewDecoder(r.Body).Decode(&pr.Body)
46+
}
47+
l.mu.Lock()
48+
l.reqs = append(l.reqs, pr)
49+
l.mu.Unlock()
50+
}
51+
52+
// firstMatch returns the first recorded request whose path equals target.
53+
func (l *reqLog) firstMatch(path string) (publishImportReq, bool) {
54+
l.mu.Lock()
55+
defer l.mu.Unlock()
56+
for _, r := range l.reqs {
57+
if r.Path == path {
58+
return r, true
59+
}
60+
}
61+
return publishImportReq{}, false
62+
}
63+
64+
// ── mock server ───────────────────────────────────────────────────────────────
65+
66+
// newMockServer starts an httptest.Server that:
67+
// - records all incoming requests in the returned *reqLog
68+
// - responds with the JSON body registered for each path (200 OK)
69+
// - falls back to 200 + empty JSON object for unregistered paths
70+
func newMockServer(t *testing.T, routes map[string]interface{}) (*httptest.Server, *reqLog) {
71+
t.Helper()
72+
log := &reqLog{}
73+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
log.record(r)
75+
body, ok := routes[r.URL.Path]
76+
if !ok {
77+
body = map[string]interface{}{}
78+
}
79+
w.Header().Set("Content-Type", "application/json")
80+
_ = json.NewEncoder(w).Encode(body)
81+
}))
82+
t.Cleanup(srv.Close)
83+
return srv, log
84+
}
85+
86+
// ── filesystem helpers ────────────────────────────────────────────────────────
87+
88+
func writeJSONFile(t *testing.T, path string, v interface{}) {
89+
t.Helper()
90+
data, err := json.MarshalIndent(v, "", " ")
91+
require.NoError(t, err)
92+
require.NoError(t, os.WriteFile(path, data, 0600))
93+
}
94+
95+
// seedAuth writes an unexpired auth token to <homeDir>/.openboot/auth.json.
96+
func seedAuth(t *testing.T, homeDir, token, username string) {
97+
t.Helper()
98+
dir := filepath.Join(homeDir, ".openboot")
99+
require.NoError(t, os.MkdirAll(dir, 0700))
100+
writeJSONFile(t, filepath.Join(dir, "auth.json"), map[string]interface{}{
101+
"token": token,
102+
"username": username,
103+
"expires_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
104+
"created_at": time.Now().Format(time.RFC3339),
105+
})
106+
}
107+
108+
// seedSyncSource writes a sync source to <homeDir>/.openboot/sync_source.json.
109+
// This simulates a machine that has previously installed a cloud config, so
110+
// `snapshot --publish` (without --slug) resolves to an update (PUT) rather
111+
// than an interactive create (POST).
112+
func seedSyncSource(t *testing.T, homeDir, username, slug string) {
113+
t.Helper()
114+
dir := filepath.Join(homeDir, ".openboot")
115+
require.NoError(t, os.MkdirAll(dir, 0700))
116+
writeJSONFile(t, filepath.Join(dir, "sync_source.json"), map[string]interface{}{
117+
"user_slug": username + "/" + slug,
118+
"username": username,
119+
"slug": slug,
120+
"synced_at": time.Now().Format(time.RFC3339),
121+
"installed_at": time.Now().Format(time.RFC3339),
122+
})
123+
}
124+
125+
// ── process helpers ───────────────────────────────────────────────────────────
126+
127+
// isolatedEnv returns an environment slice suitable for test binary invocations:
128+
// - HOME is replaced with an isolated temp directory
129+
// - All OPENBOOT_* vars from the parent process are stripped
130+
// - OPENBOOT_API_URL is pointed at the mock server
131+
// - OPENBOOT_DISABLE_AUTOUPDATE suppresses the GitHub version check
132+
func isolatedEnv(homeDir, apiURL string) []string {
133+
var env []string
134+
for _, e := range os.Environ() {
135+
if strings.HasPrefix(e, "HOME=") || strings.HasPrefix(e, "OPENBOOT_") {
136+
continue
137+
}
138+
env = append(env, e)
139+
}
140+
return append(env,
141+
"HOME="+homeDir,
142+
"OPENBOOT_API_URL="+apiURL,
143+
"OPENBOOT_DISABLE_AUTOUPDATE=1",
144+
)
145+
}
146+
147+
// runBinary executes the openboot binary with the given args and environment,
148+
// returning stdout, stderr, and the process exit error.
149+
func runBinary(t *testing.T, binary string, env []string, args ...string) (stdout, stderr string, err error) {
150+
t.Helper()
151+
cmd := exec.Command(binary, args...)
152+
cmd.Env = env
153+
var outBuf, errBuf strings.Builder
154+
cmd.Stdout = &outBuf
155+
cmd.Stderr = &errBuf
156+
return outBuf.String(), errBuf.String(), cmd.Run()
157+
}
158+
159+
// ── tests ─────────────────────────────────────────────────────────────────────
160+
161+
// TestE2E_Publish_UpdateViaExplicitSlug verifies the P7 invariant:
162+
// `snapshot --publish --slug X` must send a PUT (not POST) to
163+
// /api/configs/from-snapshot carrying the target slug in the body and the
164+
// auth token in the Authorization header.
165+
func TestE2E_Publish_UpdateViaExplicitSlug(t *testing.T) {
166+
binary := testutil.BuildTestBinary(t)
167+
home := t.TempDir()
168+
169+
const (
170+
token = "e2e-test-bearer-token"
171+
username = "alice"
172+
slug = "dev-setup"
173+
)
174+
seedAuth(t, home, token, username)
175+
176+
srv, log := newMockServer(t, map[string]interface{}{
177+
"/api/configs/from-snapshot": map[string]string{"slug": slug},
178+
})
179+
180+
_, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL),
181+
"snapshot", "--publish", "--slug", slug)
182+
t.Logf("stderr:\n%s", stderr)
183+
require.NoError(t, err, "publish --slug should succeed against mock server")
184+
185+
req, ok := log.firstMatch("/api/configs/from-snapshot")
186+
require.True(t, ok, "binary must call /api/configs/from-snapshot")
187+
188+
assert.Equal(t, http.MethodPut, req.Method,
189+
"updating an existing config must use PUT, not POST")
190+
assert.Equal(t, "Bearer "+token, req.Auth,
191+
"Authorization header must carry the stored Bearer token")
192+
require.NotNil(t, req.Body, "request body must be present")
193+
assert.Equal(t, slug, req.Body["config_slug"],
194+
"body must contain config_slug so the server knows which config to update")
195+
assert.Contains(t, req.Body, "snapshot",
196+
"body must embed the captured snapshot object")
197+
}
198+
199+
// TestE2E_Publish_UpdateViaSyncSource verifies the P7 invariant:
200+
// when no --slug flag is given but a sync source exists on disk,
201+
// `snapshot --publish` resolves to an update (PUT) using that source's slug,
202+
// and the output names the config being updated.
203+
func TestE2E_Publish_UpdateViaSyncSource(t *testing.T) {
204+
binary := testutil.BuildTestBinary(t)
205+
home := t.TempDir()
206+
207+
const (
208+
token = "e2e-sync-source-token"
209+
username = "bob"
210+
slug = "my-env"
211+
)
212+
seedAuth(t, home, token, username)
213+
seedSyncSource(t, home, username, slug)
214+
215+
srv, log := newMockServer(t, map[string]interface{}{
216+
"/api/configs/from-snapshot": map[string]string{"slug": slug},
217+
})
218+
219+
_, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL), "snapshot", "--publish")
220+
t.Logf("stderr:\n%s", stderr)
221+
require.NoError(t, err, "publish with a saved sync source should succeed")
222+
223+
req, ok := log.firstMatch("/api/configs/from-snapshot")
224+
require.True(t, ok, "binary must call /api/configs/from-snapshot")
225+
226+
assert.Equal(t, http.MethodPut, req.Method,
227+
"sync-source update must use PUT")
228+
assert.Equal(t, "Bearer "+token, req.Auth)
229+
require.NotNil(t, req.Body)
230+
assert.Equal(t, slug, req.Body["config_slug"],
231+
"body must carry the sync source's slug")
232+
233+
// P7: output must identify the config being updated ("Publishing to @user/slug").
234+
assert.Contains(t, stderr, username+"/"+slug,
235+
"output must name the config being updated")
236+
}
237+
238+
// TestE2E_Install_FetchesCloudConfig verifies that
239+
// `install user/slug --dry-run --silent` makes exactly a
240+
// GET /{user}/{slug}/config request with the stored Bearer token and exits 0.
241+
// The installer runs in dry-run mode so no packages are installed.
242+
func TestE2E_Install_FetchesCloudConfig(t *testing.T) {
243+
binary := testutil.BuildTestBinary(t)
244+
home := t.TempDir()
245+
246+
const (
247+
token = "e2e-install-bearer-token"
248+
username = "carol"
249+
slug = "team-config"
250+
)
251+
seedAuth(t, home, token, username)
252+
253+
configPath := "/" + username + "/" + slug + "/config"
254+
srv, log := newMockServer(t, map[string]interface{}{
255+
configPath: map[string]interface{}{
256+
"packages": []string{"git"},
257+
"casks": []string{},
258+
"taps": []string{},
259+
"npm": []string{},
260+
"preset": "minimal",
261+
},
262+
})
263+
264+
_, stderr, err := runBinary(t, binary, isolatedEnv(home, srv.URL),
265+
"install", username+"/"+slug, "--dry-run", "--silent")
266+
t.Logf("stderr:\n%s", stderr)
267+
require.NoError(t, err, "install --dry-run --silent should exit 0")
268+
269+
req, ok := log.firstMatch(configPath)
270+
require.True(t, ok, "binary must fetch %s", configPath)
271+
272+
assert.Equal(t, http.MethodGet, req.Method)
273+
assert.Equal(t, "Bearer "+token, req.Auth,
274+
"install must forward the stored Bearer token when fetching a cloud config")
275+
}

0 commit comments

Comments
 (0)