| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "runtime" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "testing" |
| 17 | |
| 18 | "golang.org/x/crypto/ssh" |
| 19 | |
| 20 | "reasonix/internal/browser" |
| 21 | "reasonix/internal/remote/sftpfs" |
| 22 | "reasonix/internal/remote/sshtest" |
| 23 | ) |
| 24 | |
| 25 | // brokerFakeExecutor answers from fixed fields and records the session each |
| 26 | // call arrived with (the HTTP handler restores it into the context). |
| 27 | type brokerFakeExecutor struct { |
| 28 | mu sync.Mutex |
| 29 | tabs []browser.Tab |
| 30 | screenshot browser.Screenshot |
| 31 | downloads []browser.Download |
| 32 | sessions []string |
| 33 | uploadDirectory string |
| 34 | onAct func(browser.ActRequest) |
| 35 | } |
| 36 | |
| 37 | func (e *brokerFakeExecutor) note(ctx context.Context) { |
| 38 | e.mu.Lock() |
| 39 | e.sessions = append(e.sessions, browser.SessionFromContext(ctx)) |
| 40 | e.mu.Unlock() |
| 41 | } |
| 42 | |
| 43 | func (e *brokerFakeExecutor) lastSession() string { |
| 44 | e.mu.Lock() |
| 45 | defer e.mu.Unlock() |
| 46 | if len(e.sessions) == 0 { |
| 47 | return "" |
| 48 | } |
| 49 | return e.sessions[len(e.sessions)-1] |
| 50 | } |
| 51 | |
| 52 | func (e *brokerFakeExecutor) Tabs(ctx context.Context) ([]browser.Tab, error) { |
| 53 | e.note(ctx) |
| 54 | return e.tabs, nil |
| 55 | } |
| 56 | func (e *brokerFakeExecutor) Open(ctx context.Context, req browser.OpenRequest) (browser.Tab, error) { |
| 57 | e.note(ctx) |
| 58 | return browser.Tab{ID: "tab-new", URL: req.URL}, nil |
| 59 | } |
| 60 | func (e *brokerFakeExecutor) Navigate(ctx context.Context, req browser.NavigateRequest) (browser.Tab, error) { |
| 61 | e.note(ctx) |
| 62 | return browser.Tab{ID: req.TabID, URL: req.URL}, nil |
| 63 | } |
| 64 | func (e *brokerFakeExecutor) Snapshot(ctx context.Context, _ browser.SnapshotRequest) (browser.Snapshot, error) { |
| 65 | e.note(ctx) |
| 66 | return browser.Snapshot{DocumentToken: "doc-1"}, nil |
| 67 | } |
| 68 | func (e *brokerFakeExecutor) Screenshot(ctx context.Context, _ browser.ScreenshotRequest) (browser.Screenshot, error) { |
| 69 | e.note(ctx) |
| 70 | return e.screenshot, nil |
| 71 | } |
| 72 | func (e *brokerFakeExecutor) Act(ctx context.Context, req browser.ActRequest) (browser.ActResult, error) { |
| 73 | e.note(ctx) |
| 74 | if e.onAct != nil { |
| 75 | e.onAct(req) |
| 76 | } |
| 77 | return browser.ActResult{Executed: true, Outcome: browser.OutcomeExecuted}, nil |
| 78 | } |
| 79 | |
| 80 | func (e *brokerFakeExecutor) captureDir() (string, error) { |
| 81 | if e.uploadDirectory == "" { |
| 82 | return "", errors.New("no upload directory") |
| 83 | } |
| 84 | return e.uploadDirectory, nil |
| 85 | } |
| 86 | func (e *brokerFakeExecutor) Downloads(ctx context.Context, _ browser.DownloadsRequest) ([]browser.Download, error) { |
| 87 | e.note(ctx) |
| 88 | return e.downloads, nil |
| 89 | } |
| 90 | func (e *brokerFakeExecutor) Close(ctx context.Context, _ browser.CloseRequest) error { |
| 91 | e.note(ctx) |
| 92 | return nil |
| 93 | } |
| 94 | |
| 95 | // brokerTestRig is a running broker with fake resolution, liveness and relay. |
| 96 | type brokerTestRig struct { |
| 97 | broker *browserBroker |
| 98 | baseURL string |
| 99 | gen *managedHost |
| 100 | // current flips liveness; guarded by mu for the -race runs. |
| 101 | mu sync.Mutex |
| 102 | live bool |
| 103 | conn sftpConn |
| 104 | relays []relayCall |
| 105 | relayTo string |
| 106 | } |
| 107 | |
| 108 | type relayCall struct { |
| 109 | workspace string |
| 110 | localPath string |
| 111 | } |
| 112 | |
| 113 | func newBrokerTestRig(t *testing.T, resolve browserSessionResolver) *brokerTestRig { |
| 114 | t.Helper() |
| 115 | rig := &brokerTestRig{live: true, gen: &managedHost{}} |
| 116 | rig.broker = newBrowserBroker( |
| 117 | resolve, |
| 118 | func(string, *managedHost) bool { rig.mu.Lock(); defer rig.mu.Unlock(); return rig.live }, |
| 119 | func(string, *managedHost) sftpConn { return rig.conn }, |
| 120 | ) |
| 121 | rig.broker.newRelay = func(sftpConn) FileRelay { return rig } |
| 122 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | rig.broker.ln = ln |
| 127 | rig.broker.port = ln.Addr().(*net.TCPAddr).Port |
| 128 | server := &http.Server{Handler: rig.broker} |
| 129 | rig.broker.server = server |
| 130 | go func() { _ = server.Serve(ln) }() |
| 131 | t.Cleanup(func() { rig.broker.close() }) |
| 132 | rig.baseURL = fmt.Sprintf("http://127.0.0.1:%d", rig.broker.port) |
| 133 | return rig |
| 134 | } |
| 135 | |
| 136 | // Stage implements FileRelay over the rig, recording the call. |
| 137 | func (r *brokerTestRig) Stage(_ context.Context, workspace, localPath string) (string, error) { |
| 138 | r.mu.Lock() |
| 139 | defer r.mu.Unlock() |
| 140 | r.relays = append(r.relays, relayCall{workspace: workspace, localPath: localPath}) |
| 141 | if r.relayTo == "" { |
| 142 | return "", fmt.Errorf("relay unavailable") |
| 143 | } |
| 144 | return r.relayTo + filepath.Base(localPath), nil |
| 145 | } |
| 146 | |
| 147 | func (r *brokerTestRig) Fetch(_ context.Context, workspace, remotePath, localDirectory string) (string, error) { |
| 148 | if workspace != "/ws" { |
| 149 | return "", fmt.Errorf("wrong workspace %s", workspace) |
| 150 | } |
| 151 | destination := filepath.Join(localDirectory, filepath.Base(remotePath)) |
| 152 | return destination, os.WriteFile(destination, []byte("remote bytes: "+remotePath), 0o600) |
| 153 | } |
| 154 | |
| 155 | func TestBrowserBrokerUploadStagesRemoteBytesAndCleansUp(t *testing.T) { |
| 156 | stagedPaths := make(chan string, 1) |
| 157 | exec := &brokerFakeExecutor{uploadDirectory: t.TempDir(), onAct: func(req browser.ActRequest) { |
| 158 | if len(req.Files) != 1 || req.Files[0] == "/ws/report.csv" { |
| 159 | t.Errorf("remote path reached local executor: %v", req.Files) |
| 160 | return |
| 161 | } |
| 162 | staged := req.Files[0] |
| 163 | data, err := os.ReadFile(staged) |
| 164 | if err != nil || string(data) != "remote bytes: /ws/report.csv" { |
| 165 | t.Errorf("staged upload: %q %v", data, err) |
| 166 | } |
| 167 | stagedPaths <- staged |
| 168 | }} |
| 169 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 170 | rig.conn = fakeSFTPConn{} |
| 171 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | client := browser.NewHTTPExecutor(rig.baseURL, token, nil) |
| 176 | res, err := client.Act(browser.WithSession(context.Background(), "/s"), browser.ActRequest{OperationID: "upload", Action: browser.ActionUpload, Files: []string{"/ws/report.csv"}}) |
| 177 | if err != nil || !res.Executed { |
| 178 | t.Fatalf("upload: %+v %v", res, err) |
| 179 | } |
| 180 | var staged string |
| 181 | select { |
| 182 | case staged = <-stagedPaths: |
| 183 | default: |
| 184 | t.Fatal("no upload dispatched") |
| 185 | } |
| 186 | if _, err := os.Stat(staged); !os.IsNotExist(err) { |
| 187 | t.Fatalf("staging remained after request: %v", err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | type browserGatedBody struct { |
| 192 | entered chan struct{} |
| 193 | closed chan struct{} |
| 194 | once sync.Once |
| 195 | } |
| 196 | |
| 197 | func (b *browserGatedBody) Read([]byte) (int, error) { |
| 198 | close(b.entered) |
| 199 | <-b.closed |
| 200 | return 0, io.ErrClosedPipe |
| 201 | } |
| 202 | func (b *browserGatedBody) Close() error { b.once.Do(func() { close(b.closed) }); return nil } |
| 203 | |
| 204 | func TestBrowserBrokerRevokesRequestWhileBodyIsPending(t *testing.T) { |
| 205 | exec := &brokerFakeExecutor{} |
| 206 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 207 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 208 | if err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | body := &browserGatedBody{entered: make(chan struct{}), closed: make(chan struct{})} |
| 212 | req := httptest.NewRequest(http.MethodPost, "/v1/browser/act", body) |
| 213 | req.Header.Set("Authorization", "Bearer "+token) |
| 214 | req.Header.Set(browser.SessionHeader, "/s") |
| 215 | done := make(chan struct{}) |
| 216 | go func() { defer close(done); rig.broker.ServeHTTP(httptest.NewRecorder(), req) }() |
| 217 | <-body.entered |
| 218 | rig.broker.revokeHost("host-1") |
| 219 | <-done |
| 220 | if exec.lastSession() != "" { |
| 221 | t.Fatal("revoked request dispatched") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestBrowserBrokerRechecksGenerationAfterSessionResolution(t *testing.T) { |
| 226 | exec := &brokerFakeExecutor{} |
| 227 | entered, release := make(chan struct{}), make(chan struct{}) |
| 228 | rig := newBrokerTestRig(t, func(string, string) (browserSessionResolution, error) { |
| 229 | close(entered) |
| 230 | <-release |
| 231 | return browserSessionResolution{exec: exec, workspace: "/ws"}, nil |
| 232 | }) |
| 233 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | done := make(chan error, 1) |
| 238 | go func() { |
| 239 | _, err := browser.NewHTTPExecutor(rig.baseURL, token, nil).Act(browser.WithSession(context.Background(), "/s"), browser.ActRequest{OperationID: "act", Action: browser.ActionClick}) |
| 240 | done <- err |
| 241 | }() |
| 242 | <-entered |
| 243 | rig.setLive(false) |
| 244 | close(release) |
| 245 | if err := <-done; !errors.Is(err, browser.ErrNoGrant) { |
| 246 | t.Fatalf("superseded request: %v", err) |
| 247 | } |
| 248 | if exec.lastSession() != "" { |
| 249 | t.Fatal("superseded request reached executor") |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func (r *brokerTestRig) setLive(live bool) { |
| 254 | r.mu.Lock() |
| 255 | r.live = live |
| 256 | r.mu.Unlock() |
| 257 | } |
| 258 | |
| 259 | func (r *brokerTestRig) relayCalls() []relayCall { |
| 260 | r.mu.Lock() |
| 261 | defer r.mu.Unlock() |
| 262 | return append([]relayCall(nil), r.relays...) |
| 263 | } |
| 264 | |
| 265 | func brokerTabsCall(t *testing.T, baseURL, token, session string) (int, []browser.Tab) { |
| 266 | t.Helper() |
| 267 | exec := browser.NewHTTPExecutor(baseURL, token, nil) |
| 268 | tabs, err := exec.Tabs(browser.WithSession(context.Background(), session)) |
| 269 | if err != nil { |
| 270 | return statusOfBrokerError(t, baseURL, token, session), nil |
| 271 | } |
| 272 | return http.StatusOK, tabs |
| 273 | } |
| 274 | |
| 275 | // statusOfBrokerError re-issues the call raw to read the status code the |
| 276 | // executor mapped into an error. |
| 277 | func statusOfBrokerError(t *testing.T, baseURL, token, session string) int { |
| 278 | t.Helper() |
| 279 | req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/browser/tabs", strings.NewReader(`{}`)) |
| 280 | if err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | req.Header.Set("Authorization", "Bearer "+token) |
| 284 | req.Header.Set("Content-Type", "application/json") |
| 285 | if session != "" { |
| 286 | req.Header.Set(browser.SessionHeader, session) |
| 287 | } |
| 288 | resp, err := http.DefaultClient.Do(req) |
| 289 | if err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | defer resp.Body.Close() |
| 293 | _, _ = io.Copy(io.Discard, resp.Body) |
| 294 | return resp.StatusCode |
| 295 | } |
| 296 | |
| 297 | func sessionResolver(exec browser.Executor, workspace string, allowed map[string]bool) browserSessionResolver { |
| 298 | return func(hostID, sessionPath string) (browserSessionResolution, error) { |
| 299 | if !allowed[sessionPath] { |
| 300 | return browserSessionResolution{}, fmt.Errorf("%w: no desktop tab serves session %s", browser.ErrNoGrant, sessionPath) |
| 301 | } |
| 302 | return browserSessionResolution{exec: exec, workspace: workspace}, nil |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | func TestBrowserBrokerRoundTripRoutesSession(t *testing.T) { |
| 307 | exec := &brokerFakeExecutor{tabs: []browser.Tab{{ID: "b1", URL: "https://example.test"}}} |
| 308 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/sessions/a.jsonl": true})) |
| 309 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 310 | if err != nil { |
| 311 | t.Fatal(err) |
| 312 | } |
| 313 | status, tabs := brokerTabsCall(t, rig.baseURL, token, "/sessions/a.jsonl") |
| 314 | if status != http.StatusOK || len(tabs) != 1 || tabs[0].ID != "b1" { |
| 315 | t.Fatalf("status=%d tabs=%+v", status, tabs) |
| 316 | } |
| 317 | if got := exec.lastSession(); got != "/sessions/a.jsonl" { |
| 318 | t.Fatalf("executor saw session %q", got) |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | func TestBrowserBrokerTokenRotationRevokesOldGeneration(t *testing.T) { |
| 323 | exec := &brokerFakeExecutor{} |
| 324 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 325 | oldToken, _, err := rig.broker.register("host-1", rig.gen) |
| 326 | if err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | newGen := &managedHost{} |
| 330 | newToken, _, err := rig.broker.register("host-1", newGen) |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | if oldToken == newToken { |
| 335 | t.Fatal("token rotation reused the old token") |
| 336 | } |
| 337 | if status := statusOfBrokerError(t, rig.baseURL, oldToken, "/s"); status != http.StatusUnauthorized { |
| 338 | t.Fatalf("old generation token = %d, want 401", status) |
| 339 | } |
| 340 | if status := statusOfBrokerError(t, rig.baseURL, newToken, "/s"); status != http.StatusOK { |
| 341 | t.Fatalf("new generation token = %d, want 200", status) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestBrowserBrokerRejectsDeadGeneration(t *testing.T) { |
| 346 | exec := &brokerFakeExecutor{} |
| 347 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 348 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 349 | if err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | rig.setLive(false) |
| 353 | if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized { |
| 354 | t.Fatalf("dead generation = %d, want 401", status) |
| 355 | } |
| 356 | rig.setLive(true) |
| 357 | if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusOK { |
| 358 | t.Fatalf("live generation = %d, want 200", status) |
| 359 | } |
| 360 | rig.broker.revokeHost("host-1") |
| 361 | if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized { |
| 362 | t.Fatalf("revoked host = %d, want 401", status) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | func TestBrowserBrokerRejectsForeignAndMissingSessions(t *testing.T) { |
| 367 | exec := &brokerFakeExecutor{} |
| 368 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/sessions/a.jsonl": true})) |
| 369 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 370 | if err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | for _, session := range []string{"/sessions/other.jsonl", ""} { |
| 374 | status := statusOfBrokerError(t, rig.baseURL, token, session) |
| 375 | if status != http.StatusConflict { |
| 376 | t.Fatalf("session %q = %d, want 409 no_grant", session, status) |
| 377 | } |
| 378 | } |
| 379 | if len(exec.sessions) != 0 { |
| 380 | t.Fatalf("executor saw %d calls from rejected sessions", len(exec.sessions)) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestBrowserBrokerRejectsBadToken(t *testing.T) { |
| 385 | rig := newBrokerTestRig(t, sessionResolver(&brokerFakeExecutor{}, "/ws", map[string]bool{"/s": true})) |
| 386 | if _, _, err := rig.broker.register("host-1", rig.gen); err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | for _, token := range []string{"", "wrong"} { |
| 390 | if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized { |
| 391 | t.Fatalf("token %q = %d, want 401", token, status) |
| 392 | } |
| 393 | } |
| 394 | req, err := http.NewRequest(http.MethodGet, rig.baseURL+"/healthz", nil) |
| 395 | if err != nil { |
| 396 | t.Fatal(err) |
| 397 | } |
| 398 | resp, err := http.DefaultClient.Do(req) |
| 399 | if err != nil { |
| 400 | t.Fatal(err) |
| 401 | } |
| 402 | _ = resp.Body.Close() |
| 403 | if resp.StatusCode != http.StatusNoContent { |
| 404 | t.Fatalf("healthz = %d, want 204", resp.StatusCode) |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | func TestBrowserBrokerScreenshotRelaysCapture(t *testing.T) { |
| 409 | exec := &brokerFakeExecutor{screenshot: browser.Screenshot{Path: "/tmp/reasonix-browser/tab-1/shot.png", MIME: "image/png", Width: 10, Height: 10}} |
| 410 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 411 | rig.conn = fakeSFTPConn{} |
| 412 | rig.relayTo = "/remote/scratch/" |
| 413 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 414 | if err != nil { |
| 415 | t.Fatal(err) |
| 416 | } |
| 417 | httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil) |
| 418 | shot, err := httpExec.Screenshot(browser.WithSession(context.Background(), "/s"), browser.ScreenshotRequest{TabID: "tab-1"}) |
| 419 | if err != nil { |
| 420 | t.Fatal(err) |
| 421 | } |
| 422 | if shot.Path != "/remote/scratch/shot.png" { |
| 423 | t.Fatalf("screenshot path = %q, want the relayed remote path", shot.Path) |
| 424 | } |
| 425 | calls := rig.relayCalls() |
| 426 | if len(calls) != 1 || calls[0].workspace != "/ws" || calls[0].localPath != "/tmp/reasonix-browser/tab-1/shot.png" { |
| 427 | t.Fatalf("relay calls = %+v", calls) |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | func TestBrowserBrokerScreenshotWithoutConnectionFails(t *testing.T) { |
| 432 | exec := &brokerFakeExecutor{screenshot: browser.Screenshot{Path: "/tmp/shot.png"}} |
| 433 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 434 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 435 | if err != nil { |
| 436 | t.Fatal(err) |
| 437 | } |
| 438 | httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil) |
| 439 | if _, err := httpExec.Screenshot(browser.WithSession(context.Background(), "/s"), browser.ScreenshotRequest{TabID: "t"}); err == nil { |
| 440 | t.Fatal("screenshot without a live connection succeeded") |
| 441 | } |
| 442 | if calls := rig.relayCalls(); len(calls) != 0 { |
| 443 | t.Fatalf("relay ran without a connection: %+v", calls) |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | func TestBrowserBrokerDownloadsRelayEachPath(t *testing.T) { |
| 448 | exec := &brokerFakeExecutor{downloads: []browser.Download{ |
| 449 | {ID: "d1", Path: "/tmp/dl/a.zip"}, |
| 450 | {ID: "d2", Path: ""}, |
| 451 | }} |
| 452 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 453 | rig.conn = fakeSFTPConn{} |
| 454 | rig.relayTo = "/remote/scratch/" |
| 455 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 456 | if err != nil { |
| 457 | t.Fatal(err) |
| 458 | } |
| 459 | httpExec := browser.NewHTTPExecutor(rig.baseURL, token, nil) |
| 460 | downloads, err := httpExec.Downloads(browser.WithSession(context.Background(), "/s"), browser.DownloadsRequest{TabID: "t"}) |
| 461 | if err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | if len(downloads) != 2 || downloads[0].Path != "/remote/scratch/a.zip" || downloads[1].Path != "" { |
| 465 | t.Fatalf("downloads = %+v", downloads) |
| 466 | } |
| 467 | if calls := rig.relayCalls(); len(calls) != 1 { |
| 468 | t.Fatalf("relay calls = %+v, want exactly the non-empty path", calls) |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // fakeSFTPConn satisfies sftpConn without a server; Stage never reaches it in |
| 473 | // rig-based tests because newRelay is faked. |
| 474 | type fakeSFTPConn struct{} |
| 475 | |
| 476 | func (fakeSFTPConn) SFTP() (*sftpfs.FS, error) { return nil, fmt.Errorf("no sftp") } |
| 477 | |
| 478 | func TestBrowserBrokerConcurrentRotation(t *testing.T) { |
| 479 | exec := &brokerFakeExecutor{tabs: []browser.Tab{{ID: "b1"}}} |
| 480 | rig := newBrokerTestRig(t, sessionResolver(exec, "/ws", map[string]bool{"/s": true})) |
| 481 | token, _, err := rig.broker.register("host-1", rig.gen) |
| 482 | if err != nil { |
| 483 | t.Fatal(err) |
| 484 | } |
| 485 | var wg sync.WaitGroup |
| 486 | for range 8 { |
| 487 | wg.Go(func() { |
| 488 | for range 20 { |
| 489 | _, _, _ = rig.broker.register("host-1", &managedHost{}) |
| 490 | } |
| 491 | }) |
| 492 | } |
| 493 | for range 8 { |
| 494 | wg.Go(func() { |
| 495 | for range 20 { |
| 496 | _, _ = brokerTabsCall(t, rig.baseURL, token, "/s") |
| 497 | } |
| 498 | }) |
| 499 | } |
| 500 | wg.Wait() |
| 501 | // After the dust settles only the last minted token authenticates. |
| 502 | if status := statusOfBrokerError(t, rig.baseURL, token, "/s"); status != http.StatusUnauthorized { |
| 503 | t.Fatalf("superseded token = %d, want 401", status) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | func TestSFTPFileRelayRoundTrip(t *testing.T) { |
| 508 | root := t.TempDir() |
| 509 | server := sshtest.Start(t, sshtest.Options{Password: "pw", SFTPRoot: root}) |
| 510 | cl, err := ssh.Dial("tcp", server.Addr, &ssh.ClientConfig{ |
| 511 | User: "u", |
| 512 | Auth: []ssh.AuthMethod{ssh.Password("pw")}, |
| 513 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), |
| 514 | }) |
| 515 | if err != nil { |
| 516 | t.Fatal(err) |
| 517 | } |
| 518 | defer cl.Close() |
| 519 | fs, err := sftpfs.New(cl) |
| 520 | if err != nil { |
| 521 | t.Fatal(err) |
| 522 | } |
| 523 | local := filepath.Join(t.TempDir(), "shot.png") |
| 524 | if err := os.WriteFile(local, []byte("png-bytes"), 0o600); err != nil { |
| 525 | t.Fatal(err) |
| 526 | } |
| 527 | remotePath, err := (sftpFileRelay{conn: sftpFSConn{fs: fs}}).Stage(context.Background(), "/work space", local) |
| 528 | if err != nil { |
| 529 | t.Fatal(err) |
| 530 | } |
| 531 | data, err := os.ReadFile(remotePath) |
| 532 | if err != nil { |
| 533 | t.Fatalf("relayed file unreadable at %q: %v", remotePath, err) |
| 534 | } |
| 535 | if string(data) != "png-bytes" { |
| 536 | t.Fatalf("relayed content = %q", data) |
| 537 | } |
| 538 | if !strings.Contains(remotePath, "browser-relay") { |
| 539 | t.Fatalf("remote path %q is outside the relay scratch area", remotePath) |
| 540 | } |
| 541 | info, err := os.Stat(remotePath) |
| 542 | if err != nil { |
| 543 | t.Fatal(err) |
| 544 | } |
| 545 | // Windows exposes only the read-only bit through chmod; the Windows |
| 546 | // round-trip still checks native file access and both transfer directions. |
| 547 | if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { |
| 548 | t.Fatalf("relayed file mode = %v, want 0600", info.Mode().Perm()) |
| 549 | } |
| 550 | // The reverse direction must download the remote bytes and enforce the |
| 551 | // remote workspace boundary before handing a desktop path to Chromium. |
| 552 | relay := sftpFileRelay{conn: sftpFSConn{fs: fs}} |
| 553 | staged, err := relay.Fetch(context.Background(), filepath.Dir(remotePath), remotePath, t.TempDir()) |
| 554 | if err != nil { |
| 555 | t.Fatal(err) |
| 556 | } |
| 557 | data, err = os.ReadFile(staged) |
| 558 | if err != nil || string(data) != "png-bytes" { |
| 559 | t.Fatalf("remote upload content = %q %v", data, err) |
| 560 | } |
| 561 | if _, err := relay.Fetch(context.Background(), filepath.Join(root, "unowned"), local, t.TempDir()); err == nil { |
| 562 | t.Fatal("foreign remote file was staged") |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | type sftpFSConn struct{ fs *sftpfs.FS } |
| 567 | |
| 568 | func (c sftpFSConn) SFTP() (*sftpfs.FS, error) { return c.fs, nil } |
| 569 | |
| 570 | func TestSFTPFileRelayRejectsOddFiles(t *testing.T) { |
| 571 | dir := t.TempDir() |
| 572 | relay := sftpFileRelay{conn: sftpFSConn{}} |
| 573 | if _, err := relay.Stage(context.Background(), "/ws", filepath.Join(dir, "missing.png")); err == nil { |
| 574 | t.Fatal("missing file staged") |
| 575 | } |
| 576 | if _, err := relay.Stage(context.Background(), "/ws", dir); err == nil { |
| 577 | t.Fatal("directory staged") |
| 578 | } |
| 579 | } |
| 580 | |
| 581 | func TestRelayFileNameSanitizes(t *testing.T) { |
| 582 | name := relayFileName("/tmp/x/evil.png") |
| 583 | if strings.Contains(name, "/") || !strings.HasSuffix(name, "-evil.png") { |
| 584 | t.Fatalf("relayFileName = %q", name) |
| 585 | } |
| 586 | if name == relayFileName("/tmp/x/evil.png") { |
| 587 | t.Fatal("relayFileName must be unique per call") |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | func TestBrowserRelayRemotePathContract(t *testing.T) { |
| 592 | for _, tc := range []struct { |
| 593 | name, home, agentPath, wirePath string |
| 594 | }{ |
| 595 | {"posix", "/home/user", "/workspace/report.csv", "/workspace/report.csv"}, |
| 596 | {"posix backslash filename", "/home/user", "/workspace/report\\name.csv", "/workspace/report\\name.csv"}, |
| 597 | {"posix drive-like directory", "/home/user", "/C:/report.csv", "/C:/report.csv"}, |
| 598 | {"windows native", "/C:/Users/user", `C:\work space\report.csv`, "/C:/work space/report.csv"}, |
| 599 | {"windows other drive", "/C:/Users/user", `D:\work space\report.csv`, "/D:/work space/report.csv"}, |
| 600 | {"windows forward slashes", "/C:/Users/user", "C:/work space/report.csv", "/C:/work space/report.csv"}, |
| 601 | {"windows canonical", "/C:/Users/user", "/C:/work space/report.csv", "/C:/work space/report.csv"}, |
| 602 | {"windows unprefixed server", "C:/Users/user", `D:\work space\report.csv`, "D:/work space/report.csv"}, |
| 603 | } { |
| 604 | t.Run(tc.name, func(t *testing.T) { |
| 605 | if got := relaySFTPPath(tc.agentPath, tc.home); got != tc.wirePath { |
| 606 | t.Fatalf("wire path = %q, want %q", got, tc.wirePath) |
| 607 | } |
| 608 | if got := relaySFTPPath(relayAgentPath(tc.wirePath, tc.home), tc.home); got != tc.wirePath { |
| 609 | t.Fatalf("Agent path round trip = %q, want %q", got, tc.wirePath) |
| 610 | } |
| 611 | }) |
| 612 | } |
| 613 | for _, wire := range []string{"/C:/Users/user/capture.png", "D:/work/report.csv"} { |
| 614 | if got := relayAgentPath(wire, "/C:/Users/user"); !relayWindowsDrivePath(got) || strings.HasPrefix(got, "/") { |
| 615 | t.Fatalf("Windows Agent cannot consume %q", got) |
| 616 | } |
| 617 | } |
| 618 | if got := relayAgentPath("/C:/report.csv", "/home/user"); got != "/C:/report.csv" { |
| 619 | t.Fatalf("POSIX directory changed to a Windows drive: %q", got) |
| 620 | } |
| 621 | for _, relative := range []string{"C:report.csv", "report.csv", "../report.csv"} { |
| 622 | if relayWindowsDrivePath(relative) { |
| 623 | t.Fatalf("relative path accepted as absolute: %q", relative) |
| 624 | } |
| 625 | } |
| 626 | } |
| 627 |