Files
tailscale/cmd/tsconnect/wasm/bridge_test.go
T
2026-08-30 23:32:56 +00:00

327 lines
9.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js && wasm
package main
import (
"context"
"errors"
"io"
"net/http/httptest"
"strings"
"syscall/js"
"testing"
"time"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
)
func TestMakePromiseRejectsJSError(t *testing.T) {
errValue := js.Global().Get("Error").New("boom")
promise := makePromise(func() (any, error) {
panic(js.Error{Value: errValue})
})
rejection := awaitPromise(t, promise)
if rejection.Type() != js.TypeObject || rejection.Get("message").String() != "boom" {
t.Fatalf("rejection = %v, want Error(boom)", rejection)
}
}
func TestDriveHandlerThrowReturns500(t *testing.T) {
fn := evalJS(t, `(function() { throw new Error("boom") })`)
fs := new(jsFileSystemForRemote)
fs.setHandler(fn)
recorder := httptest.NewRecorder()
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
if recorder.Code != 500 {
t.Fatalf("status = %d, want 500", recorder.Code)
}
}
func TestDriveCancellationDisablesCallbacks(t *testing.T) {
type handlerArgs struct {
req js.Value
res js.Value
}
started := make(chan handlerArgs, 1)
deferred := evalJS(t, `(() => {
let resolve
const promise = new Promise(r => { resolve = r })
return {promise, resolve}
})()`)
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
started <- handlerArgs{req: args[0], res: args[1]}
return deferred.Get("promise")
})
defer handler.Release()
fs := new(jsFileSystemForRemote)
fs.setHandler(handler.Value)
ctx, cancel := context.WithCancel(context.Background())
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
close(done)
}()
var jsArgs handlerArgs
select {
case jsArgs = <-started:
case <-time.After(time.Second):
t.Fatal("drive handler did not start")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("drive handler did not stop after cancellation")
}
readResult := jsArgs.req.Call("readBodyChunk")
if readResult.Type() != js.TypeObject {
t.Fatalf("readBodyChunk after cancellation returned %s, want Promise", readResult.Type())
}
if result := awaitFulfilledPromise(t, readResult); !result.IsNull() {
t.Fatalf("readBodyChunk after cancellation = %v, want null", result)
}
lateChunk := js.Global().Get("Uint8Array").New(1)
js.CopyBytesToJS(lateChunk, []byte("x"))
jsArgs.res.Call("write", lateChunk)
if recorder.Body.Len() != 0 {
t.Fatalf("late JS write changed response body to %q", recorder.Body.String())
}
deferred.Call("resolve")
writeStarted := make(chan struct{})
allowWrite := make(chan struct{})
response := &driveResponse{
w: recorder,
doneCh: make(chan error, 1),
live: true,
testBeforeWriteCheck: func() {
close(writeStarted)
<-allowWrite
},
}
chunk := js.Global().Get("Uint8Array").New(1)
js.CopyBytesToJS(chunk, []byte("x"))
writeReturned := make(chan struct{})
go func() {
response.write([]js.Value{chunk})
close(writeReturned)
}()
select {
case <-writeStarted:
case <-time.After(time.Second):
t.Fatal("response write did not start")
}
response.finish(context.Canceled, context.Canceled)
close(allowWrite)
select {
case <-writeReturned:
case <-time.After(time.Second):
t.Fatal("response callback did not finish")
}
if recorder.Body.Len() != 0 {
t.Fatalf("late write changed response body to %q", recorder.Body.String())
}
}
func TestDriveHandlerFulfillmentCompletesRequest(t *testing.T) {
handler := evalJS(t, `(function() { return Promise.resolve() })`)
fs := new(jsFileSystemForRemote)
fs.setHandler(handler)
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
fs.ServeHTTPWithPerms(nil, recorder, httptest.NewRequest("GET", "/", nil))
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("fulfilled drive handler did not complete request")
}
}
func TestJSStreamReaderErrorsAreSticky(t *testing.T) {
for _, test := range []struct {
name string
reader string
}{
{"rejected", `({read() { return Promise.reject("boom") }, cancel() { return Promise.resolve() }})`},
{"throwing", `({read() { throw new Error("boom") }, cancel() { return Promise.resolve() }})`},
} {
t.Run(test.name, func(t *testing.T) {
r := &jsStreamReader{reader: evalJS(t, test.reader)}
_, first := r.Read(make([]byte, 1))
_, second := r.Read(make([]byte, 1))
if first == nil || second == nil || first.Error() != second.Error() || !strings.Contains(first.Error(), "boom") {
t.Fatalf("read errors = %v, %v; want matching boom errors", first, second)
}
})
}
}
func TestJSStreamReaderCloseErrors(t *testing.T) {
for _, test := range []struct {
name string
reader string
}{
{"rejected", `({cancel() { return Promise.reject("boom") }})`},
{"throwing", `({cancel() { throw new Error("boom") }})`},
} {
t.Run(test.name, func(t *testing.T) {
r := &jsStreamReader{reader: evalJS(t, test.reader)}
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Close error = %v, want boom", err)
}
})
}
}
func TestJSStreamReaderCloseTimesOut(t *testing.T) {
r := &jsStreamReader{
reader: evalJS(t, `({cancel() { return new Promise(() => {}) }})`),
testCancelTimeout: 10 * time.Millisecond,
}
started := time.Now()
if err := r.Close(); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Close error = %v, want timeout", err)
}
if elapsed := time.Since(started); elapsed >= 250*time.Millisecond {
t.Fatalf("Close took %v, want less than 250ms", elapsed)
}
if _, err := r.Read(make([]byte, 1)); err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("Read error after Close timeout = %v, want sticky timeout", err)
}
}
func TestJSStreamReaderIgnoresDuplicateSettlement(t *testing.T) {
reader := evalJS(t, `({
read() {
return {then(resolve, reject) { resolve({done: true}); reject("late") }}
},
cancel() {
return {then(resolve, reject) { resolve(); reject("late") }}
},
})`)
r := &jsStreamReader{reader: reader}
if _, err := r.Read(make([]byte, 1)); !errors.Is(err, io.EOF) {
t.Fatalf("Read error = %v, want EOF", err)
}
if err := r.Close(); err != nil {
t.Fatalf("Close error = %v, want nil", err)
}
}
func TestNotifyRefreshesNetMap(t *testing.T) {
online := true
lastSeen := time.Now()
for _, test := range []struct {
name string
n ipn.Notify
want bool
}{
{"self", ipn.Notify{SelfChange: new(tailcfg.Node)}, true},
{"peer changed", ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, true},
{"peer removed", ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, true},
{"online patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{Online: &online}}}, true},
{"last seen patch", ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{LastSeen: &lastSeen}}}, false},
{"state only", ipn.Notify{State: new(ipn.State)}, false},
} {
t.Run(test.name, func(t *testing.T) {
if got := notifyRefreshesNetMap(test.n); got != test.want {
t.Fatalf("notifyRefreshesNetMap() = %v, want %v", got, test.want)
}
})
}
}
func TestNotifyWatchMask(t *testing.T) {
want := ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyPeerChanges | ipn.NotifyPeerPatches
if jsIPNNotifyWatchMask != want {
t.Fatalf("jsIPNNotifyWatchMask = %v, want %v", jsIPNNotifyWatchMask, want)
}
if jsIPNNotifyWatchMask&ipn.NotifyInProcessNoDisconnect != 0 {
t.Fatal("jsIPNNotifyWatchMask must allow a lagging watcher to disconnect")
}
}
func evalJS(t *testing.T, source string) js.Value {
t.Helper()
value, err := callJSFunction(js.Global().Get("eval"), source)
if err != nil {
t.Fatal(err)
}
return value
}
func awaitPromise(t *testing.T, promise js.Value) js.Value {
t.Helper()
type result struct {
value js.Value
rejected bool
}
ch := make(chan result, 1)
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0]}
return nil
})
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0], rejected: true}
return nil
})
defer resolve.Release()
defer reject.Release()
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
t.Fatal(err)
}
select {
case result := <-ch:
if !result.rejected {
t.Fatal(errors.New("promise resolved; want rejection"))
}
return result.value
case <-time.After(time.Second):
t.Fatal("promise did not settle")
return js.Undefined()
}
}
func awaitFulfilledPromise(t *testing.T, promise js.Value) js.Value {
t.Helper()
type result struct {
value js.Value
rejected bool
}
ch := make(chan result, 1)
resolve := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0]}
return nil
})
reject := js.FuncOf(func(this js.Value, args []js.Value) any {
ch <- result{value: args[0], rejected: true}
return nil
})
defer resolve.Release()
defer reject.Release()
if _, err := callJSMethod(promise, "then", resolve, reject); err != nil {
t.Fatal(err)
}
select {
case result := <-ch:
if result.rejected {
t.Fatalf("promise rejected with %v; want fulfillment", result.value)
}
return result.value
case <-time.After(time.Second):
t.Fatal("promise did not settle")
return js.Undefined()
}
}