// 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() } }