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}