client_test.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5package silverbullet
  6
  7import (
  8	"context"
  9	"encoding/json"
 10	"fmt"
 11	"net/http"
 12	"net/http/httptest"
 13	"sync"
 14	"testing"
 15	"time"
 16)
 17
 18func newTestServer(mux *http.ServeMux) *httptest.Server {
 19	return httptest.NewServer(mux)
 20}
 21
 22func testClient(url string) *Client {
 23	return New(url, Auth{})
 24}
 25
 26func testClientWithBasicAuth(url string) *Client {
 27	return New(url, Auth{User: "testuser", Pass: "testpass"})
 28}
 29
 30func setupAuthServer(mux *http.ServeMux) {
 31	mux.HandleFunc("/.auth", func(w http.ResponseWriter, r *http.Request) {
 32		if r.Method != http.MethodPost {
 33			w.WriteHeader(http.StatusMethodNotAllowed)
 34			return
 35		}
 36		username := r.FormValue("username")
 37		password := r.FormValue("password")
 38		if username != "testuser" || password != "testpass" {
 39			w.WriteHeader(http.StatusUnauthorized)
 40			return
 41		}
 42		http.SetCookie(w, &http.Cookie{
 43			Name:  "auth_session",
 44			Value: "mock-jwt-token",
 45		})
 46		w.WriteHeader(http.StatusOK)
 47	})
 48}
 49
 50func testClientWithBearerToken(url string) *Client {
 51	return New(url, Auth{Token: "testtoken123"})
 52}
 53
 54func TestExecuteLua(t *testing.T) {
 55	mux := http.NewServeMux()
 56	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
 57		if r.Method != http.MethodPost {
 58			t.Errorf("expected POST, got %s", r.Method)
 59		}
 60		if r.Header.Get("Content-Type") != "text/plain" {
 61			t.Errorf("expected Content-Type text/plain, got %s", r.Header.Get("Content-Type"))
 62		}
 63
 64		result := LuaResult{Result: json.RawMessage(`2`)}
 65		_ = json.NewEncoder(w).Encode(result)
 66	})
 67
 68	srv := newTestServer(mux)
 69	defer srv.Close()
 70
 71	client := testClient(srv.URL)
 72	result, err := client.ExecuteLua(context.Background(), "1 + 1", 30)
 73	if err != nil {
 74		t.Fatalf("ExecuteLua failed: %v", err)
 75	}
 76
 77	if result.Result == nil {
 78		t.Fatal("expected result, got nil")
 79	}
 80}
 81
 82func TestExecuteLuaError(t *testing.T) {
 83	mux := http.NewServeMux()
 84	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
 85		w.Header().Set("Content-Type", "application/json")
 86		w.WriteHeader(http.StatusInternalServerError)
 87		_, _ = fmt.Fprintf(w, `{"error":"attempt to call nil value"}`)
 88	})
 89
 90	srv := newTestServer(mux)
 91	defer srv.Close()
 92
 93	client := testClient(srv.URL)
 94	result, err := client.ExecuteLua(context.Background(), "bad()", 30)
 95	if err != nil {
 96		t.Fatalf("ExecuteLua returned unexpected error: %v", err)
 97	}
 98
 99	if result.Error == "" {
100		t.Fatal("expected error in result, got empty string")
101	}
102}
103
104func TestExecuteLuaTimeoutHeader(t *testing.T) {
105	mux := http.NewServeMux()
106	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
107		timeout := r.Header.Get("X-Timeout")
108		if timeout != "60" {
109			t.Errorf("expected X-Timeout 60, got %s", timeout)
110		}
111		result := LuaResult{Result: json.RawMessage(`"ok"`)}
112		_ = json.NewEncoder(w).Encode(result)
113	})
114
115	srv := newTestServer(mux)
116	defer srv.Close()
117
118	client := testClient(srv.URL)
119	_, err := client.ExecuteLua(context.Background(), "return 'ok'", 60)
120	if err != nil {
121		t.Fatalf("ExecuteLua failed: %v", err)
122	}
123}
124
125func TestExecuteLuaTimeoutClamp(t *testing.T) {
126	mux := http.NewServeMux()
127	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
128		// Timeout should be clamped to 21600 even if caller passes 50000
129		timeout := r.Header.Get("X-Timeout")
130		if timeout != "21600" {
131			t.Errorf("expected X-Timeout 21600, got %s", timeout)
132		}
133		result := LuaResult{Result: json.RawMessage(`"ok"`)}
134		_ = json.NewEncoder(w).Encode(result)
135	})
136
137	srv := newTestServer(mux)
138	defer srv.Close()
139
140	client := testClient(srv.URL)
141	_, err := client.ExecuteLua(context.Background(), "return 'ok'", 50000)
142	if err != nil {
143		t.Fatalf("ExecuteLua failed: %v", err)
144	}
145}
146
147func TestScreenshot(t *testing.T) {
148	mux := http.NewServeMux()
149	mux.HandleFunc("/.runtime/screenshot", func(w http.ResponseWriter, r *http.Request) {
150		if r.Method != http.MethodGet {
151			t.Errorf("expected GET, got %s", r.Method)
152		}
153		w.Header().Set("Content-Type", "image/png")
154		_, _ = w.Write([]byte("fake-png-data"))
155	})
156
157	srv := newTestServer(mux)
158	defer srv.Close()
159
160	client := testClient(srv.URL)
161	data, err := client.Screenshot(context.Background())
162	if err != nil {
163		t.Fatalf("Screenshot failed: %v", err)
164	}
165
166	if string(data) != "fake-png-data" {
167		t.Errorf("expected fake-png-data, got %s", string(data))
168	}
169}
170
171func TestConsoleLogs(t *testing.T) {
172	mux := http.NewServeMux()
173	mux.HandleFunc("/.runtime/logs", func(w http.ResponseWriter, r *http.Request) {
174		if r.Method != http.MethodGet {
175			t.Errorf("expected GET, got %s", r.Method)
176		}
177		limit := r.URL.Query().Get("limit")
178		if limit != "5" {
179			t.Errorf("expected limit=5, got %s", limit)
180		}
181		since := r.URL.Query().Get("since")
182		if since != "1000" {
183			t.Errorf("expected since=1000, got %s", since)
184		}
185
186		logs := LogsResult{
187			Logs: []LogEntry{
188				{Level: "log", Text: "Booting", Timestamp: 1710000000000},
189				{Level: "info", Text: "Ready", Timestamp: 1710000000050},
190			},
191		}
192		_ = json.NewEncoder(w).Encode(logs)
193	})
194
195	srv := newTestServer(mux)
196	defer srv.Close()
197
198	client := testClient(srv.URL)
199	result, err := client.ConsoleLogs(context.Background(), 5, 1000)
200	if err != nil {
201		t.Fatalf("ConsoleLogs failed: %v", err)
202	}
203
204	if len(result.Logs) != 2 {
205		t.Fatalf("expected 2 log entries, got %d", len(result.Logs))
206	}
207
208	if result.Logs[0].Text != "Booting" {
209		t.Errorf("expected first log text 'Booting', got %s", result.Logs[0].Text)
210	}
211}
212
213func TestConsoleLogsDefaultLimit(t *testing.T) {
214	mux := http.NewServeMux()
215	mux.HandleFunc("/.runtime/logs", func(w http.ResponseWriter, r *http.Request) {
216		limit := r.URL.Query().Get("limit")
217		if limit != "100" {
218			t.Errorf("expected default limit=100, got %s", limit)
219		}
220		logs := LogsResult{Logs: []LogEntry{}}
221		_ = json.NewEncoder(w).Encode(logs)
222	})
223
224	srv := newTestServer(mux)
225	defer srv.Close()
226
227	client := testClient(srv.URL)
228	_, err := client.ConsoleLogs(context.Background(), 0, 0)
229	if err != nil {
230		t.Fatalf("ConsoleLogs failed: %v", err)
231	}
232}
233
234func TestBasicAuth(t *testing.T) {
235	mux := http.NewServeMux()
236	setupAuthServer(mux)
237	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
238		// Verify session cookie is present
239		cookie, err := r.Cookie("auth_session")
240		if err != nil {
241			t.Error("expected auth_session cookie, got none")
242		}
243		if cookie != nil && cookie.Value != "mock-jwt-token" {
244			t.Errorf("expected cookie value 'mock-jwt-token', got %s", cookie.Value)
245		}
246
247		if r.Header.Get("X-Timeout") == "" {
248			t.Error("expected X-Timeout header to be set")
249		}
250
251		result := LuaResult{Result: json.RawMessage(`"ok"`)}
252		_ = json.NewEncoder(w).Encode(result)
253	})
254
255	srv := newTestServer(mux)
256	defer srv.Close()
257
258	client := testClientWithBasicAuth(srv.URL)
259	_, err := client.ExecuteLua(context.Background(), "return 'ok'", 30)
260	if err != nil {
261		t.Fatalf("ExecuteLua with basic auth failed: %v", err)
262	}
263}
264
265func TestBearerToken(t *testing.T) {
266	mux := http.NewServeMux()
267	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
268		auth := r.Header.Get("Authorization")
269		if auth != "Bearer testtoken123" {
270			t.Errorf("expected Bearer token, got %s", auth)
271		}
272		result := LuaResult{Result: json.RawMessage(`"ok"`)}
273		_ = json.NewEncoder(w).Encode(result)
274	})
275
276	srv := newTestServer(mux)
277	defer srv.Close()
278
279	client := testClientWithBearerToken(srv.URL)
280	_, err := client.ExecuteLua(context.Background(), "return 'ok'", 30)
281	if err != nil {
282		t.Fatalf("ExecuteLua with bearer token failed: %v", err)
283	}
284}
285
286func TestBothAuth(t *testing.T) {
287	mux := http.NewServeMux()
288	setupAuthServer(mux)
289	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
290		// Bearer token on Authorization header
291		auth := r.Header.Get("Authorization")
292		if auth != "Bearer testtoken123" {
293			t.Errorf("expected Bearer token on Authorization, got %s", auth)
294		}
295		// Session cookie from /.auth login
296		cookie, err := r.Cookie("auth_session")
297		if err != nil {
298			t.Error("expected auth_session cookie, got none")
299		}
300		if cookie != nil && cookie.Value != "mock-jwt-token" {
301			t.Errorf("expected cookie value 'mock-jwt-token', got %s", cookie.Value)
302		}
303		result := LuaResult{Result: json.RawMessage(`"ok"`)}
304		_ = json.NewEncoder(w).Encode(result)
305	})
306
307	srv := newTestServer(mux)
308	defer srv.Close()
309
310	client := New(srv.URL, Auth{User: "testuser", Pass: "testpass", Token: "testtoken123"})
311	_, err := client.ExecuteLua(context.Background(), "return 'ok'", 30)
312	if err != nil {
313		t.Fatalf("ExecuteLua with both auth types failed: %v", err)
314	}
315}
316
317func TestPasswordAuthReloginsAfterCookieExpires(t *testing.T) {
318	mux := http.NewServeMux()
319	var mu sync.Mutex
320	loginCount := 0
321	currentToken := ""
322
323	mux.HandleFunc("/.auth", func(w http.ResponseWriter, r *http.Request) {
324		if r.Method != http.MethodPost {
325			w.WriteHeader(http.StatusMethodNotAllowed)
326			return
327		}
328		if r.FormValue("username") != "testuser" || r.FormValue("password") != "testpass" {
329			w.WriteHeader(http.StatusUnauthorized)
330			return
331		}
332
333		mu.Lock()
334		loginCount++
335		currentToken = fmt.Sprintf("mock-jwt-token-%d", loginCount)
336		token := currentToken
337		mu.Unlock()
338
339		http.SetCookie(w, &http.Cookie{
340			Name:   "auth_session",
341			Value:  token,
342			MaxAge: 1,
343		})
344		w.WriteHeader(http.StatusOK)
345	})
346	mux.HandleFunc("/.runtime/logs", func(w http.ResponseWriter, r *http.Request) {
347		cookie, err := r.Cookie("auth_session")
348		if err != nil {
349			t.Error("expected auth_session cookie, got none")
350			w.WriteHeader(http.StatusUnauthorized)
351			return
352		}
353
354		mu.Lock()
355		expectedToken := currentToken
356		mu.Unlock()
357		if cookie.Value != expectedToken {
358			t.Errorf("expected cookie value %q, got %q", expectedToken, cookie.Value)
359		}
360		_ = json.NewEncoder(w).Encode(LogsResult{Logs: []LogEntry{}})
361	})
362
363	srv := newTestServer(mux)
364	defer srv.Close()
365
366	client := testClientWithBasicAuth(srv.URL)
367	if _, err := client.ConsoleLogs(context.Background(), 1, 0); err != nil {
368		t.Fatalf("first ConsoleLogs failed: %v", err)
369	}
370	time.Sleep(1100 * time.Millisecond)
371	if _, err := client.ConsoleLogs(context.Background(), 1, 0); err != nil {
372		t.Fatalf("second ConsoleLogs failed: %v", err)
373	}
374
375	mu.Lock()
376	defer mu.Unlock()
377	if loginCount != 2 {
378		t.Fatalf("expected 2 logins after cookie expiry, got %d", loginCount)
379	}
380}
381
382func TestPasswordAuthRefreshesRejectedSession(t *testing.T) {
383	mux := http.NewServeMux()
384	var mu sync.Mutex
385	loginCount := 0
386
387	mux.HandleFunc("/.auth", func(w http.ResponseWriter, r *http.Request) {
388		if r.Method != http.MethodPost {
389			w.WriteHeader(http.StatusMethodNotAllowed)
390			return
391		}
392		if r.FormValue("username") != "testuser" || r.FormValue("password") != "testpass" {
393			w.WriteHeader(http.StatusUnauthorized)
394			return
395		}
396
397		mu.Lock()
398		loginCount++
399		token := fmt.Sprintf("mock-jwt-token-%d", loginCount)
400		mu.Unlock()
401
402		http.SetCookie(w, &http.Cookie{
403			Name:  "auth_session",
404			Value: token,
405		})
406		w.WriteHeader(http.StatusOK)
407	})
408	mux.HandleFunc("/.runtime/lua_script", func(w http.ResponseWriter, r *http.Request) {
409		cookie, err := r.Cookie("auth_session")
410		if err != nil {
411			t.Error("expected auth_session cookie, got none")
412			w.WriteHeader(http.StatusUnauthorized)
413			return
414		}
415		if cookie.Value == "mock-jwt-token-1" {
416			w.WriteHeader(http.StatusUnauthorized)
417			return
418		}
419		if cookie.Value != "mock-jwt-token-2" {
420			t.Errorf("expected refreshed cookie value, got %q", cookie.Value)
421		}
422
423		result := LuaResult{Result: json.RawMessage(`"ok"`)}
424		_ = json.NewEncoder(w).Encode(result)
425	})
426
427	srv := newTestServer(mux)
428	defer srv.Close()
429
430	client := testClientWithBasicAuth(srv.URL)
431	if _, err := client.ExecuteLua(context.Background(), "return 'ok'", 30); err != nil {
432		t.Fatalf("ExecuteLua failed: %v", err)
433	}
434
435	mu.Lock()
436	defer mu.Unlock()
437	if loginCount != 2 {
438		t.Fatalf("expected 2 logins after rejected session, got %d", loginCount)
439	}
440}
441
442func TestTrailingSlashURL(t *testing.T) {
443	// Verify that trailing slashes in the base URL are handled correctly
444	client := New("http://localhost:3000/", Auth{})
445	if client.baseURL != "http://localhost:3000" {
446		t.Errorf("expected trailing slash stripped, got %s", client.baseURL)
447	}
448
449	endpoint, err := client.resolveURL("/.runtime/lua_script")
450	if err != nil {
451		t.Fatalf("resolveURL failed: %v", err)
452	}
453	if endpoint != "http://localhost:3000/.runtime/lua_script" {
454		t.Errorf("expected no double slash, got %s", endpoint)
455	}
456}