Files
Piik/internal/server/app/app_test.go
T
AAA 35f9251088 fix: preserve room authority and repair Browser/Client recovery (#386)
* fix: preserve room authority and recover current media connections

* docs: close room authority and recovery integration
2026-09-10 01:30:07 +08:00

1526 lines
54 KiB
Go

package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
"github.com/coder/websocket"
_ "modernc.org/sqlite"
"github.com/TNTcraftHIM/Screener/internal/server/config"
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
"github.com/TNTcraftHIM/Screener/internal/server/room"
)
// The scenarios below are ported from tests/server-http.test.ts.
const allowedOrigin = "http://allowed.test"
// setCookiePattern is the exact attribute order access-session.ts emits and
// app.ts writes verbatim.
var setCookiePattern = regexp.MustCompile(
`^screener-site-access=v1\.[0-9]+\.[A-Za-z0-9_-]{43}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict$`)
var secureSetCookiePattern = regexp.MustCompile(
`^__Host-screener-site-access=v1\.[0-9]+\.[A-Za-z0-9_-]{43}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict; Secure$`)
func testConfig(t *testing.T) config.Config {
t.Helper()
publicBaseURL, err := url.Parse("https://share.example.test")
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
return config.Config{
Env: config.EnvironmentDevelopment,
Port: 0,
ListenHost: "127.0.0.1",
PublicBaseURL: publicBaseURL,
AllowedOrigins: map[string]struct{}{allowedOrigin: {}},
SiteAccessPassword: testAccessPassword,
MaxViewersPerRoom: 8,
EndpointMediaCopyCapacity: 2,
NATPredictionEnabled: false,
}
}
// newServer builds a server without listening. It mirrors the TS harness: the
// heartbeat interval is pushed out of the way and a fake room
// control is injected whenever a LiveKit fallback is configured.
func newServer(t *testing.T, options Options) *Server {
t.Helper()
if options.Config.PublicBaseURL == nil {
options.Config = testConfig(t)
}
if options.HeartbeatIntervalMs == 0 {
options.HeartbeatIntervalMs = 60_000
}
server, err := New(options)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { _ = server.Close(context.Background()) })
return server
}
type harness struct {
*Server
t *testing.T
baseURL string
client *http.Client
}
func start(t *testing.T, options Options) *harness {
t.Helper()
server := newServer(t, options)
port, err := server.Listen(context.Background())
if err != nil {
t.Fatalf("Listen: %v", err)
}
return &harness{Server: server, t: t, baseURL: baseURL(port), client: testClient(t)}
}
func baseURL(port int) string { return fmt.Sprintf("http://127.0.0.1:%d", port) }
// testClient never reuses a connection, so a closed server never leaves a
// pooled socket behind for the next test.
func testClient(t *testing.T) *http.Client {
t.Helper()
transport := &http.Transport{DisableKeepAlives: true}
t.Cleanup(transport.CloseIdleConnections)
return &http.Client{Transport: transport, Timeout: 30 * time.Second}
}
// freePort reserves and releases a port so a test can know the listen address
// before Listen returns. The window is a few microseconds on a test machine.
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
t.Fatalf("close probe listener: %v", err)
}
return port
}
type reply struct {
t *testing.T
status int
header http.Header
body string
}
type requestOption func(*http.Request)
func withOrigin(origin string) requestOption {
return func(request *http.Request) { request.Header.Set("Origin", origin) }
}
func withCookie(cookie string) requestOption {
return func(request *http.Request) {
if cookie != "" {
request.Header.Set("Cookie", cookie)
}
}
}
func withBearer(token string) requestOption {
return func(request *http.Request) {
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
}
}
func withBody(contentType, payload string) requestOption {
return func(request *http.Request) {
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
request.Body = io.NopCloser(strings.NewReader(payload))
request.ContentLength = int64(len(payload))
}
}
func withJSON(payload string) requestOption {
return withBody("application/json", payload)
}
func (h *harness) do(method, path string, options ...requestOption) reply {
h.t.Helper()
return send(h.t, h.client, method, h.baseURL+path, options...)
}
func send(
t *testing.T,
client *http.Client,
method, target string,
options ...requestOption,
) reply {
t.Helper()
request, err := http.NewRequest(method, target, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
for _, option := range options {
option(request)
}
response, err := client.Do(request)
if err != nil {
t.Fatalf("%s %s: %v", method, target, err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return reply{t: t, status: response.StatusCode, header: response.Header, body: string(body)}
}
func (r reply) expect(status int, body string) reply {
r.t.Helper()
if r.status != status {
r.t.Fatalf("status = %d, want %d (body %s)", r.status, status, r.body)
}
if r.body != body {
r.t.Fatalf("body = %s, want %s", r.body, body)
}
return r
}
func (r reply) expectStatus(status int) reply {
r.t.Helper()
if r.status != status {
r.t.Fatalf("status = %d, want %d (body %s)", r.status, status, r.body)
}
return r
}
func (r reply) expectHeader(name, want string) reply {
r.t.Helper()
if got := r.header.Get(name); got != want {
r.t.Fatalf("header %s = %q, want %q", name, got, want)
}
return r
}
func (r reply) room() protocol.CreateRoomResponse {
r.t.Helper()
var created protocol.CreateRoomResponse
if err := json.Unmarshal([]byte(r.body), &created); err != nil {
r.t.Fatalf("decode CreateRoomResponse from %s: %v", r.body, err)
}
return created
}
// login is the TS login(): a POST with the site password as a bearer token.
func (h *harness) login() reply {
h.t.Helper()
return h.do(http.MethodPost, "/api/site-access",
withBearer(testAccessPassword), withOrigin(allowedOrigin))
}
func (h *harness) cookie() string {
h.t.Helper()
response := h.login().expectStatus(http.StatusOK)
return cookiePair(h.t, response.header.Get("Set-Cookie"))
}
type roomRequest struct {
cookie string
codeEntryPolicy string
password *string
preferredRoomID string
}
func (h *harness) createRoom(request roomRequest) reply {
h.t.Helper()
policy := request.codeEntryPolicy
if policy == "" {
policy = "open"
}
fields := []string{fmt.Sprintf("%q:%q", "codeEntryPolicy", policy)}
if request.password != nil {
fields = append(fields, fmt.Sprintf("%q:%q", "roomPassword", *request.password))
}
if request.preferredRoomID != "" {
fields = append(fields, fmt.Sprintf("%q:%q", "preferredRoomId", request.preferredRoomID))
}
return h.do(http.MethodPost, "/api/rooms",
withOrigin(allowedOrigin), withCookie(request.cookie),
withJSON("{"+strings.Join(fields, ",")+"}"))
}
type accessRequest struct {
roomID string
hostToken string
body string
cookie string
origin string
method string
}
func (h *harness) updateRoomAccess(request accessRequest) reply {
h.t.Helper()
method := request.method
if method == "" {
method = http.MethodPost
}
origin := request.origin
if origin == "" {
origin = allowedOrigin
}
options := []requestOption{
withOrigin(origin), withCookie(request.cookie), withBearer(request.hostToken),
}
if method == http.MethodPost {
options = append(options, withJSON(request.body))
}
return h.do(method, "/api/rooms/"+request.roomID+"/access", options...)
}
func (h *harness) replaceRoom(roomID, hostToken, body, cookie string) reply {
h.t.Helper()
return h.do(http.MethodPost, "/api/rooms/"+roomID+"/replacement",
withOrigin(allowedOrigin), withCookie(cookie), withBearer(hostToken),
withJSON(body))
}
func waitFor(t *testing.T, what string, condition func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !condition() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(2 * time.Millisecond)
}
}
func assertPortFree(t *testing.T, port int) {
t.Helper()
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
t.Fatalf("port %d is still owned: %v", port, err)
}
_ = listener.Close()
}
// expectGoroutinesSettled is the close tests' stand-in for the race detector
// (unavailable without cgo): the listener's Serve loop, the signaling
// goroutines and the timers must all have exited once Close (or a failed
// Listen) returns, so the count returns to what it was before New.
func expectGoroutinesSettled(t *testing.T, before int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for runtime.NumGoroutine() > before {
if time.Now().After(deadline) {
stacks := make([]byte, 1<<20)
t.Fatalf("goroutines: %d before, %d after close\n%s",
before, runtime.NumGoroutine(), stacks[:runtime.Stack(stacks, true)])
}
time.Sleep(2 * time.Millisecond)
}
}
// --- runtime capabilities -------------------------------------------------
func TestCapabilitiesKeepNATPredictionAbsentByDefault(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"natPrediction":false}`)
}
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
configuration := testConfig(t)
configuration.STUNURLs = []string{"stun:share.example.test:3478"}
configuration.NATPredictionEnabled = true
configuration.STUNListenAddresses = []string{"127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0"}
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"natPrediction":true}`).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "nosniff")
server.do(http.MethodPost, "/api/capabilities").
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "GET")
}
func TestSTUNListenersFollowApplicationCloseAndEnd(t *testing.T) {
for _, ending := range []bool{false, true} {
t.Run(fmt.Sprintf("end=%t", ending), func(t *testing.T) {
configuration := testConfig(t)
configuration.STUNURLs = []string{"stun:share.example.test:3478"}
configuration.NATPredictionEnabled = true
configuration.STUNListenAddresses = []string{"127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0"}
server := start(t, Options{Config: configuration})
addresses := server.stunServer.Addresses
var err error
if ending {
err = server.End(context.Background())
} else {
err = server.Close(context.Background())
}
if err != nil {
t.Fatal(err)
}
assertUDPPortsFree(t, addresses)
})
}
}
func TestSFUListenerFollowsApplicationCloseAndEnd(t *testing.T) {
for _, ending := range []bool{false, true} {
t.Run(fmt.Sprintf("end=%t", ending), func(t *testing.T) {
configuration := testConfig(t)
configuration.SFU = &config.SFUConfig{ListenHost: "127.0.0.1", Port: 0}
server := start(t, Options{Config: configuration})
addresses := server.mediaMux.GetListenAddresses()
if server.signaling.Load() == nil || server.media == nil {
t.Fatal("missing runtime owner")
}
var err error
if ending {
err = server.End(context.Background())
} else {
err = server.Close(context.Background())
}
if err != nil {
t.Fatal(err)
}
assertUDPPortsFree(t, addresses)
})
}
}
func TestOccupiedSFUPortRollsBackBeforeOpeningTheDatabase(t *testing.T) {
occupied, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
configuration := testConfig(t)
configuration.Port = freePort(t)
configuration.RoomDatabasePath = filepath.Join(t.TempDir(), "rooms.sqlite")
configuration.STUNListenAddresses = []string{"127.0.0.1:0"}
configuration.SFU = &config.SFUConfig{ListenHost: "127.0.0.1", Port: occupied.LocalAddr().(*net.UDPAddr).Port}
server := newServer(t, Options{Config: configuration})
if _, err = server.Listen(context.Background()); err == nil {
t.Fatal("occupied media listener was accepted")
}
if _, err = os.Stat(configuration.RoomDatabasePath); !os.IsNotExist(err) {
t.Fatalf("SFU bind failure touched room persistence: %v", err)
}
assertPortFree(t, configuration.Port)
if server.stunServer != nil || server.mediaMux != nil {
t.Fatal("failed startup retained a UDP listener")
}
if err = occupied.SetDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("startup rollback closed another listener: %v", err)
}
}
func TestOccupiedSTUNAuxiliaryRollsBackBeforeOpeningTheDatabase(t *testing.T) {
first, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
firstAddress := first.LocalAddr()
_ = first.Close()
occupied, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
configuration := testConfig(t)
configuration.Port = freePort(t)
configuration.RoomDatabasePath = filepath.Join(t.TempDir(), "rooms.sqlite")
configuration.STUNListenAddresses = []string{
firstAddress.String(), occupied.LocalAddr().String(), "127.0.0.1:0",
}
server := newServer(t, Options{Config: configuration})
if _, err = server.Listen(context.Background()); err == nil {
t.Fatal("occupied auxiliary STUN listener was accepted")
}
if _, err = os.Stat(configuration.RoomDatabasePath); !os.IsNotExist(err) {
t.Fatalf("STUN bind failure touched room persistence: %v", err)
}
assertPortFree(t, configuration.Port)
assertUDPPortsFree(t, []net.Addr{firstAddress})
if err = occupied.SetDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("startup rollback closed another listener: %v", err)
}
}
func assertUDPPortsFree(t *testing.T, addresses []net.Addr) {
t.Helper()
for _, address := range addresses {
listener, err := net.ListenPacket("udp4", address.String())
if err != nil {
t.Fatalf("STUN listener remained owned: %v", err)
}
_ = listener.Close()
}
}
// --- site access ----------------------------------------------------------
func TestSiteAccessReportsStatusAndIssuesStatelessDayLongCookie(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/api/site-access").
expect(http.StatusOK, `{"required":true,"authenticated":false}`).
expectHeader("Cache-Control", "no-store").
expectHeader("Set-Cookie", "")
server.do(http.MethodPost, "/api/site-access",
withBearer("wrong-password"), withOrigin(allowedOrigin)).
expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`).
expectHeader("Set-Cookie", "").
expectHeader("WWW-Authenticate", "Bearer")
authenticated := server.login().
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
setCookie := authenticated.header.Get("Set-Cookie")
if !setCookiePattern.MatchString(setCookie) {
t.Fatalf("Set-Cookie = %q, want %v", setCookie, setCookiePattern)
}
if strings.Contains(setCookie, testAccessPassword) {
t.Fatal("Set-Cookie leaked the site access password")
}
cookie := cookiePair(t, setCookie)
server.do(http.MethodPost, "/api/site-access",
withBearer("wrong-password"), withCookie(cookie), withOrigin(allowedOrigin)).
expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`).
expectHeader("Set-Cookie", "")
status := server.do(http.MethodGet, "/api/site-access", withCookie(cookie)).
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
if !setCookiePattern.MatchString(status.header.Get("Set-Cookie")) {
t.Fatalf("renewal Set-Cookie = %q", status.header.Get("Set-Cookie"))
}
}
func TestSiteAccessRenewsAnAuthenticatedCookieAcrossIdleDeadlines(t *testing.T) {
var now atomic.Int64
now.Store(1_000)
server := start(t, Options{
Config: testConfig(t),
Now: now.Load,
SiteAccessTTLSeconds: 12,
})
original := cookiePair(t, server.login().header.Get("Set-Cookie"))
now.Store(7_000)
firstRenewed := cookiePair(t, server.do(
http.MethodGet, "/api/site-access", withCookie(original)).
header.Get("Set-Cookie"))
if firstRenewed == original {
t.Fatal("renewal reissued the same cookie")
}
now.Store(13_000)
server.do(http.MethodGet, "/api/site-access", withCookie(original)).
expect(http.StatusOK, `{"required":true,"authenticated":false}`).
expectHeader("Set-Cookie", "")
secondRenewed := cookiePair(t, server.do(
http.MethodGet, "/api/site-access", withCookie(firstRenewed)).
header.Get("Set-Cookie"))
if secondRenewed == firstRenewed {
t.Fatal("second renewal reissued the same cookie")
}
now.Store(20_000)
server.do(http.MethodGet, "/api/site-access", withCookie(firstRenewed)).
expect(http.StatusOK, `{"required":true,"authenticated":false}`).
expectHeader("Set-Cookie", "")
active := server.do(http.MethodGet, "/api/site-access", withCookie(secondRenewed)).
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
if active.header.Get("Set-Cookie") == "" {
t.Fatal("an active cookie was not renewed")
}
}
func TestSiteAccessUsesASecureHostCookieForProductionHTTPS(t *testing.T) {
configuration := testConfig(t)
configuration.Env = config.EnvironmentProduction
server := start(t, Options{Config: configuration})
setCookie := server.login().expectStatus(http.StatusOK).header.Get("Set-Cookie")
if !secureSetCookiePattern.MatchString(setCookie) {
t.Fatalf("Set-Cookie = %q, want %v", setCookie, secureSetCookiePattern)
}
if strings.Contains(setCookie, "Domain=") {
t.Fatalf("Set-Cookie = %q must not scope a domain", setCookie)
}
}
func TestLocalPasswordUsesConfiguredDestinationCookieAcrossLANAndPublicLink(t *testing.T) {
configuration, err := config.Local(config.LocalOptions{
Port: freePort(t), PublicAddress: "192.0.2.10", PublicOrigin: "https://public.example.test",
SiteAccessPassword: testAccessPassword,
})
if err != nil {
t.Fatal(err)
}
server := start(t, Options{Config: configuration})
lanOrigin := fmt.Sprintf("http://192.0.2.10:%d", configuration.Port)
for _, origin := range []string{lanOrigin, "https://public.example.test"} {
t.Run(origin, func(t *testing.T) {
destination, _ := url.Parse(origin)
host := func(request *http.Request) { request.Host = destination.Host }
login := server.do(http.MethodPost, "/api/site-access", host,
withOrigin(lanOrigin), withBearer(testAccessPassword)).expectStatus(http.StatusOK)
cookies := (&http.Response{Header: login.header}).Cookies()
if len(cookies) != 1 || cookies[0].Secure != (destination.Scheme == "https") {
t.Fatal("cookie policy does not match its configured destination")
}
jar, _ := cookiejar.New(nil)
jar.SetCookies(destination, cookies)
usable := jar.Cookies(destination)
if len(usable) != 1 {
t.Fatal("browser cookie rules reject the issued login")
}
cookie := usable[0].String()
server.do(http.MethodGet, "/api/site-access", host, withCookie(cookie)).
expect(http.StatusOK, `{"required":true,"authenticated":true}`)
server.do(http.MethodPost, "/api/rooms", host, withOrigin(origin),
withCookie(cookie), withJSON(`{"codeEntryPolicy":"open"}`)).expectStatus(http.StatusCreated)
upgrade := httptest.NewRequest(http.MethodGet, origin+"/signal", nil)
upgrade.Header.Set("Cookie", cookie)
upgrade.Header.Set("X-Forwarded-Proto", "http")
if !server.signalOptions.SiteAccessAtUpgrade(upgrade) {
t.Fatal("WebSocket admission does not use the same cookie policy")
}
if destination.Scheme == "https" {
upgrade.Header.Set("Cookie", strings.TrimPrefix(cookie, "__Host-"))
if server.signalOptions.SiteAccessAtUpgrade(upgrade) {
t.Fatal("public authority accepted the plain LAN cookie name")
}
}
})
}
}
func TestSiteAccessRequiresAnAllowedOriginAndAnEmptyLoginBody(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
forbidden := `{"error":"Forbidden"}`
server.do(http.MethodPost, "/api/site-access", withBearer(testAccessPassword)).
expect(http.StatusForbidden, forbidden)
server.do(http.MethodPost, "/api/site-access",
withBearer(testAccessPassword), withOrigin("https://foreign.test")).
expect(http.StatusForbidden, forbidden)
server.do(http.MethodPost, "/api/site-access",
withBearer(testAccessPassword), withOrigin(allowedOrigin+"/path")).
expect(http.StatusForbidden, forbidden)
server.do(http.MethodPost, "/api/site-access",
withBearer(testAccessPassword), withOrigin(allowedOrigin),
withBody("text/plain;charset=UTF-8", "{}")).
expect(http.StatusBadRequest, `{"error":"Request body is not accepted"}`)
server.do(http.MethodDelete, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "GET, POST")
}
func TestSiteAccessRejectsAnExpiredOrModifiedCookie(t *testing.T) {
var now atomic.Int64
now.Store(1_000)
server := start(t, Options{
Config: testConfig(t),
Now: now.Load,
SiteAccessTTLSeconds: 1,
})
cookie := cookiePair(t, server.login().header.Get("Set-Cookie"))
server.do(http.MethodGet, "/api/site-access", withCookie(cookie+"x")).
expect(http.StatusOK, `{"required":true,"authenticated":false}`)
now.Store(2_000)
server.do(http.MethodGet, "/api/site-access", withCookie(cookie)).
expect(http.StatusOK, `{"required":true,"authenticated":false}`)
}
func TestSiteAccessIsImmediateWhenTheAccessPasswordIsEmpty(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/site-access").
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
}
// --- room HTTP API --------------------------------------------------------
func TestRoomCreationRequiresALoggedInBrowser(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.createRoom(roomRequest{}).
expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`).
expectHeader("WWW-Authenticate", "")
// Hazard 12: the site password as a bearer token is not a browser session.
server.do(http.MethodPost, "/api/rooms",
withOrigin(allowedOrigin), withBearer(testAccessPassword),
withJSON(`{"codeEntryPolicy":"open"}`)).
expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`)
}
func TestRoomCreationIssuesAnIndependentFragmentOnlyViewerGrant(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
response := server.createRoom(roomRequest{cookie: server.cookie()}).
expectStatus(http.StatusCreated).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "nosniff")
created := response.room()
if !regexp.MustCompile(`^[1-9][0-9]{3}$`).MatchString(created.RoomID) {
t.Fatalf("roomId = %q", created.RoomID)
}
invite, err := url.Parse(created.InviteURL)
if err != nil {
t.Fatalf("url.Parse(inviteUrl): %v", err)
}
if config.Origin(invite) != "https://share.example.test" {
t.Fatalf("invite origin = %q", config.Origin(invite))
}
if invite.Path != "/r/"+created.RoomID {
t.Fatalf("invite path = %q", invite.Path)
}
if !regexp.MustCompile(`^v=[A-Za-z0-9_-]{21}[AQgw]$`).MatchString(invite.Fragment) {
t.Fatalf("invite fragment = %q", invite.Fragment)
}
if invite.RawQuery != "" {
t.Fatalf("invite query = %q", invite.RawQuery)
}
if created.CodeEntryPolicy != "open" {
t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy)
}
// The response carries only room authority and no ICE configuration.
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(response.body), &raw); err != nil {
t.Fatalf("decode body: %v", err)
}
for _, absent := range []string{"iceConfig", "viewerGrant"} {
if _, present := raw[absent]; present {
t.Fatalf("response exposes %q", absent)
}
}
}
func TestRoomCreationPassesTheSharedViewerCeilingToRoomAdmission(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
configuration.MaxViewersPerRoom = protocol.MaxViewersPerRoomLimit
server := start(t, Options{Config: configuration})
server.createRoom(roomRequest{}).expectStatus(http.StatusCreated)
if got := server.Store().MaxViewersPerRoom(); got != protocol.MaxViewersPerRoomLimit {
t.Fatalf("maxViewersPerRoom = %d, want %d", got, protocol.MaxViewersPerRoomLimit)
}
}
func TestRoomCreationCreatesPrivateRoomsWithOptionalPasswordsAtomically(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
cookie := server.cookie()
password := "room-password"
withPassword := server.createRoom(roomRequest{
cookie: cookie, codeEntryPolicy: "private", password: &password,
}).expectStatus(http.StatusCreated).room()
if withPassword.CodeEntryPolicy != "private" {
t.Fatalf("codeEntryPolicy = %q", withPassword.CodeEntryPolicy)
}
withoutPassword := server.createRoom(roomRequest{
cookie: cookie, codeEntryPolicy: "private",
}).expectStatus(http.StatusCreated).room()
if withoutPassword.CodeEntryPolicy != "private" {
t.Fatalf("codeEntryPolicy = %q", withoutPassword.CodeEntryPolicy)
}
}
func TestRoomCreationAllowsExplicitOpenCreationWithoutSiteAccess(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
created := server.createRoom(roomRequest{codeEntryPolicy: "open"}).
expectStatus(http.StatusCreated).room()
if created.CodeEntryPolicy != "open" {
t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy)
}
if !strings.Contains(created.InviteURL, "#v=") {
t.Fatalf("inviteUrl = %q", created.InviteURL)
}
}
func TestRoomCreationAllocatesUniqueFourDigitCodesConcurrently(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
cookie := server.cookie()
var wait sync.WaitGroup
created := make([]protocol.CreateRoomResponse, 2)
for index := range created {
wait.Add(1)
go func() {
defer wait.Done()
created[index] = server.createRoom(roomRequest{cookie: cookie}).
expectStatus(http.StatusCreated).room()
}()
}
wait.Wait()
if created[0].RoomID == created[1].RoomID {
t.Fatalf("both rooms took the code %q", created[0].RoomID)
}
}
func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *testing.T) {
configuration := testConfig(t)
server := start(t, Options{Config: configuration})
cookie := server.cookie()
preferred := server.createRoom(roomRequest{cookie: cookie, preferredRoomID: "4321"}).
expectStatus(http.StatusCreated).room()
fallback := server.createRoom(roomRequest{cookie: cookie, preferredRoomID: "4321"}).
expectStatus(http.StatusCreated).room()
if preferred.RoomID != "4321" {
t.Fatalf("preferred roomId = %q", preferred.RoomID)
}
if fallback.RoomID == "4321" {
t.Fatal("the occupied preferred code was handed out twice")
}
if fallback.HostToken == preferred.HostToken {
t.Fatal("the fallback room reused the Host token")
}
}
func TestRoomReplacementReplacesAuthorityAndClosesOldMembership(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
cookie := server.cookie()
password := "old-password"
original := server.createRoom(roomRequest{
cookie: cookie, codeEntryPolicy: "private",
password: &password, preferredRoomID: "4321",
}).expectStatus(http.StatusCreated).room()
store := server.Store()
host, err := store.ConnectParticipant(room.ConnectParticipantInput{
RoomID: original.RoomID, Role: protocol.RoleHost, Token: original.HostToken,
ClientID: "host-client", SessionID: "host-session",
})
if err != nil {
t.Fatalf("connect host: %v", err)
}
invite, err := url.Parse(original.InviteURL)
if err != nil {
t.Fatalf("url.Parse(inviteUrl): %v", err)
}
if _, err := store.ConnectParticipant(room.ConnectParticipantInput{
RoomID: original.RoomID, Role: protocol.RoleViewer,
ViewerGrant: strings.TrimPrefix(invite.Fragment, "v="),
ClientID: "viewer-client", SessionID: "viewer-session",
}); err != nil {
t.Fatalf("connect viewer: %v", err)
}
server.replaceRoom(original.RoomID, original.HostToken,
`{"codeEntryPolicy":"open"}`, "").
expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`)
replacement := server.replaceRoom(original.RoomID, original.HostToken,
`{"codeEntryPolicy":"private","roomPassword":"new-password"}`, cookie).
expectStatus(http.StatusCreated).room()
if replacement.RoomID == original.RoomID {
t.Fatal("the replacement kept the old room code")
}
if replacement.CodeEntryPolicy != "private" {
t.Fatalf("codeEntryPolicy = %q", replacement.CodeEntryPolicy)
}
if _, connected := store.GetConnectedHost(original.RoomID); connected {
t.Fatal("the old Host is still connected")
}
if viewers := store.GetConnectedViewers(original.RoomID); len(viewers) != 0 {
t.Fatalf("old viewers = %v", viewers)
}
if _, err := store.ConnectParticipant(room.ConnectParticipantInput{
RoomID: original.RoomID, Role: protocol.RoleHost, Token: original.HostToken,
ClientID: "old-host", SessionID: "old-session",
}); roomErrorCode(err) != room.CodeInvalidToken {
t.Fatalf("reconnect error = %v, want INVALID_TOKEN", err)
}
if host.PeerID == "" {
t.Fatal("the replaced Host never had a peer id")
}
}
// TestRoomMutationFailuresMapToTheHTTPTable covers the two TS cases that used
// vi.spyOn to make RoomStore reject: "keeps valid Host authority on a transient
// replacement rejection" (ROOM_ACCESS_DENIED) and "maps a busy password gate to
// 503 for every HTTP mutation" (ROOM_BUSY on all three). Go cannot monkey patch
// a concrete *room.Store, and neither code is reachable from HTTP without a
// racing second mutation, so the pure mapping functions the three routes share
// are driven directly instead; that the store leaves the authority intact when
// it rejects is the room package's own contract.
func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) {
otherError := errors.New("storage failed")
cases := []struct {
name string
mapper func(error) (int, string, bool)
err error
status int
message string
mapped bool
}{
{"replacement limit", replacementFailure, &room.Error{Code: room.CodeRoomLimit}, 503, "Room capacity reached", true},
{"replacement denied", replacementFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 503, "Room replacement unavailable", true},
{"replacement busy", replacementFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room replacement unavailable", true},
{"replacement token", replacementFailure, &room.Error{Code: room.CodeInvalidToken}, 404, "Room not found", true},
{"replacement missing", replacementFailure, &room.Error{Code: room.CodeRoomNotFound}, 404, "Room not found", true},
{"replacement full", replacementFailure, &room.Error{Code: room.CodeRoomFull}, 0, "", false},
{"replacement other", replacementFailure, otherError, 0, "", false},
{"access busy", accessFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room access update unavailable", true},
{"access denied", accessFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 409, "Room access update rejected", true},
{"access token", accessFailure, &room.Error{Code: room.CodeInvalidToken}, 404, "Room not found", true},
{"access limit", accessFailure, &room.Error{Code: room.CodeRoomLimit}, 404, "Room not found", true},
{"access other", accessFailure, otherError, 0, "", false},
{"creation limit", creationFailure, &room.Error{Code: room.CodeRoomLimit}, 503, "Room capacity reached", true},
{"creation busy", creationFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room creation unavailable", true},
{"creation token", creationFailure, &room.Error{Code: room.CodeInvalidToken}, 400, "Invalid room request", true},
{"creation denied", creationFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 0, "", false},
{"creation other", creationFailure, otherError, 0, "", false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
status, message, mapped := testCase.mapper(testCase.err)
if status != testCase.status || message != testCase.message || mapped != testCase.mapped {
t.Fatalf("got (%d, %q, %t), want (%d, %q, %t)",
status, message, mapped,
testCase.status, testCase.message, testCase.mapped)
}
})
}
// A wrapped store error still maps: the routes use errors.As.
if status, _, mapped := creationFailure(
fmt.Errorf("create: %w", &room.Error{Code: room.CodeRoomLimit}),
); !mapped || status != 503 {
t.Fatalf("wrapped ROOM_LIMIT mapped=%t status=%d", mapped, status)
}
}
func TestRoomAccessManagesADormantRoomWithoutStartingSharing(t *testing.T) {
var now atomic.Int64
configuration := testConfig(t)
server := start(t, Options{Config: configuration, Now: now.Load})
cookie := server.cookie()
created := server.createRoom(roomRequest{cookie: cookie}).
expectStatus(http.StatusCreated).room()
now.Store(900)
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie,
body: `{"action":"set-code-entry-policy","policy":"private"}`,
}).
expect(http.StatusOK,
`{"type":"code-entry-policy-updated","codeEntryPolicy":"private","viewerPasswordEnabled":false}`).
expectHeader("Cache-Control", "no-store")
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie,
body: `{"action":"set-viewer-password","password":"room-password"}`,
}).expect(http.StatusOK, `{"type":"viewer-password-updated","enabled":true}`)
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie,
body: `{"action":"set-viewer-password","password":null}`,
}).expect(http.StatusOK, `{"type":"viewer-password-updated","enabled":false}`)
rotated := server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie,
body: `{"action":"rotate-viewer-grant"}`,
}).expectStatus(http.StatusOK)
var rotatedBody struct {
Type string `json:"type"`
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
InviteURL *string `json:"inviteUrl"`
}
if err := json.Unmarshal([]byte(rotated.body), &rotatedBody); err != nil {
t.Fatalf("decode rotation: %v", err)
}
if rotatedBody.Type != "viewer-grant-updated" || rotatedBody.InviteURL == nil {
t.Fatalf("rotation body = %s", rotated.body)
}
if !regexp.MustCompile(`#v=[A-Za-z0-9_-]{22}$`).MatchString(*rotatedBody.InviteURL) {
t.Fatalf("rotated inviteUrl = %q", *rotatedBody.InviteURL)
}
revoked := server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie,
body: `{"action":"revoke-viewer-grant"}`,
}).expectStatus(http.StatusOK)
if !strings.Contains(revoked.body, `"inviteUrl":null`) ||
!strings.Contains(revoked.body, `"type":"viewer-grant-updated"`) {
t.Fatalf("revocation body = %s", revoked.body)
}
if _, connected := server.Store().GetConnectedHost(created.RoomID); connected {
t.Fatal("managing access started a sharing session")
}
now.Store(30 * 24 * 60 * 60 * 1_000)
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken, cookie: server.cookie(),
body: `{"action":"set-code-entry-policy","policy":"open"}`,
}).expectStatus(http.StatusOK)
}
func TestRoomAccessRequiresSameOriginSiteAccessAndTheExactHostToken(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
cookie := server.cookie()
first := server.createRoom(roomRequest{cookie: cookie}).
expectStatus(http.StatusCreated).room()
second := server.createRoom(roomRequest{cookie: cookie}).
expectStatus(http.StatusCreated).room()
rotate := `{"action":"rotate-viewer-grant"}`
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: first.HostToken, body: rotate,
}).expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: first.HostToken, body: rotate,
cookie: cookie, origin: "https://foreign.test",
}).expect(http.StatusForbidden, `{"error":"Forbidden"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, body: rotate, cookie: cookie,
}).expect(http.StatusNotFound, `{"error":"Room not found"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: "wrong-token", body: rotate, cookie: cookie,
}).expect(http.StatusNotFound, `{"error":"Room not found"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: second.HostToken, body: rotate, cookie: cookie,
}).expect(http.StatusNotFound, `{"error":"Room not found"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: first.HostToken, cookie: cookie,
body: `{"action":"unknown"}`,
}).expect(http.StatusBadRequest, `{"error":"Invalid room access request"}`)
server.updateRoomAccess(accessRequest{
roomID: first.RoomID, hostToken: first.HostToken, body: rotate,
cookie: cookie, method: http.MethodGet,
}).
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "POST")
}
func TestRoomCreationRejectsMalformedRequestsAndForeignBrowserOrigins(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin), withJSON("{}")).
expect(http.StatusBadRequest, `{"error":"Invalid room request"}`)
server.do(http.MethodPost, "/api/rooms",
withOrigin("https://foreign.test"), withJSON(`{"codeEntryPolicy":"open"}`)).
expect(http.StatusForbidden, `{"error":"Forbidden"}`)
// An oversized or non-JSON body is the same 400.
server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin),
withJSON(`{"codeEntryPolicy":"open","preferredRoomId":"`+strings.Repeat("1", 1_024)+`"}`)).
expect(http.StatusBadRequest, `{"error":"Invalid room request"}`)
server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin),
withBody("text/plain", `{"codeEntryPolicy":"open"}`)).
expect(http.StatusBadRequest, `{"error":"Invalid room request"}`)
server.do(http.MethodGet, "/api/rooms", withOrigin(allowedOrigin)).
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "POST")
}
func TestRoomCreationReturnsServiceUnavailableAtTheGlobalRoomBound(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
store, err := room.New(room.Options{
MaxRooms: 1, MaxViewersPerRoom: 8,
})
if err != nil {
t.Fatalf("room.New: %v", err)
}
server := start(t, Options{Config: configuration, RoomStore: store})
server.createRoom(roomRequest{}).expectStatus(http.StatusCreated)
server.createRoom(roomRequest{}).
expect(http.StatusServiceUnavailable, `{"error":"Room capacity reached"}`)
}
// --- server HTTP listener and health --------------------------------------
func TestServesAnExplicitStaticFrontendIndependentlyOfTheEnvironment(t *testing.T) {
assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<main>local-static</main>")},
"assets/app.js": &fstest.MapFile{
Data: []byte("export const marker = 1;\n"),
},
}
server := start(t, Options{Config: testConfig(t), Assets: assets})
server.do(http.MethodGet, "/local-route").
expect(http.StatusOK, "<main>local-static</main>").
expectHeader("Content-Type", "text/html;charset=utf-8").
expectHeader("X-Content-Type-Options", "nosniff").
expectHeader("Referrer-Policy", "no-referrer")
server.do(http.MethodGet, "/assets/app.js").
expect(http.StatusOK, "export const marker = 1;\n").
expectHeader("Content-Type", "text/javascript")
// /api/** never falls through to the frontend.
server.do(http.MethodGet, "/api/unknown").
expect(http.StatusNotFound, `{"error":"Not found"}`).
expectHeader("Cache-Control", "no-store")
// A missing asset with an extension keeps the JSON 404.
server.do(http.MethodGet, "/assets/missing.js").
expect(http.StatusNotFound, `{"error":"Not found"}`)
}
func TestApiOnlyCompositionAnswersJSONNotFound(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/local-route").
expect(http.StatusNotFound, `{"error":"Not found"}`).
expectHeader("Cache-Control", "")
server.do(http.MethodGet, "/api/unknown").
expect(http.StatusNotFound, `{"error":"Not found"}`).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "")
}
func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "rooms.sqlite")
var now atomic.Int64
now.Store(100)
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
configuration.RoomDatabasePath = databasePath
first := start(t, Options{Config: configuration, Now: now.Load})
created := first.createRoom(roomRequest{codeEntryPolicy: "private"}).
expectStatus(http.StatusCreated).room()
if err := first.Close(context.Background()); err != nil {
t.Fatalf("close first: %v", err)
}
now.Store(30 * 24 * 60 * 60 * 1_000)
second := start(t, Options{Config: configuration, Now: now.Load})
if size := second.Store().Size(); size != 1 {
t.Fatalf("restored room count = %d, want 1", size)
}
second.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken,
body: `{"action":"set-code-entry-policy","policy":"open"}`,
}).expect(http.StatusOK,
`{"type":"code-entry-policy-updated","codeEntryPolicy":"open","viewerPasswordEnabled":false}`)
}
func TestDoesNotOpenDatabaseWhenAnotherProcessOwnsTheListener(t *testing.T) {
owner := start(t, Options{Config: testConfig(t)})
ownerPort := ownerBoundPort(t, owner)
databasePath := filepath.Join(t.TempDir(), "missing-parent", "rooms.sqlite")
configuration := testConfig(t)
configuration.Port = ownerPort
configuration.RoomDatabasePath = databasePath
contender := newServer(t, Options{Config: configuration})
// The TS test asserted the EADDRINUSE code; Windows reports WSAEADDRINUSE,
// which errors.Is does not fold into syscall.EADDRINUSE, so the contract
// asserted here is the one that matters: the bind failed, nothing was
// reconciled, and the owner still holds the port.
_, err := contender.Listen(context.Background())
if err == nil {
t.Fatal("the contender bound a port another process owns")
}
var addressError *net.OpError
if !errors.As(err, &addressError) || addressError.Op != "listen" {
t.Fatalf("Listen error = %v, want a listen failure", err)
}
if _, statErr := os.Stat(databasePath); !os.IsNotExist(statErr) {
t.Fatalf("the room database was created: %v", statErr)
}
owner.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`)
}
// ownerBoundPort re-reads the port a started harness listens on.
func ownerBoundPort(t *testing.T, server *harness) int {
t.Helper()
parsed, err := url.Parse(server.baseURL)
if err != nil {
t.Fatalf("url.Parse: %v", err)
}
port := 0
if _, err := fmt.Sscanf(parsed.Port(), "%d", &port); err != nil {
t.Fatalf("parse port: %v", err)
}
return port
}
func TestRejectsASecondDatabaseOwnerBeforeServingTraffic(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "rooms.sqlite")
ownerConfig := testConfig(t)
ownerConfig.RoomDatabasePath = databasePath
start(t, Options{Config: ownerConfig})
contenderConfig := testConfig(t)
contenderConfig.Port = freePort(t)
contenderConfig.RoomDatabasePath = databasePath
contender := newServer(t, Options{Config: contenderConfig})
_, err := contender.Listen(context.Background())
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") {
t.Fatalf("Listen error = %v, want a locked database", err)
}
assertPortFree(t, contenderConfig.Port)
}
func TestRejectsAMismatchedDatabaseBeforeServingTraffic(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "rooms.sqlite")
raw, err := sql.Open("sqlite", "file:"+filepath.ToSlash(databasePath))
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
if _, err := raw.Exec("CREATE TABLE unrelated(value TEXT) STRICT"); err != nil {
t.Fatalf("seed unrelated schema: %v", err)
}
if err := raw.Close(); err != nil {
t.Fatalf("close raw database: %v", err)
}
configuration := testConfig(t)
configuration.Port = freePort(t)
configuration.RoomDatabasePath = databasePath
server := newServer(t, Options{Config: configuration})
_, err = server.Listen(context.Background())
if err == nil ||
!strings.Contains(err.Error(), "Room database application identity does not match") {
t.Fatalf("Listen error = %v", err)
}
assertPortFree(t, configuration.Port)
}
func TestRejectsAnInaccessibleDatabasePathBeforeServingTraffic(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "missing-parent", "rooms.sqlite")
configuration := testConfig(t)
configuration.Port = freePort(t)
configuration.RoomDatabasePath = databasePath
server := newServer(t, Options{Config: configuration})
if _, err := server.Listen(context.Background()); err == nil {
t.Fatal("Listen accepted an unreachable database path")
}
if _, err := os.Stat(databasePath); !os.IsNotExist(err) {
t.Fatalf("the room database was created: %v", err)
}
assertPortFree(t, configuration.Port)
}
func TestUsesTheConfiguredListenHostByDefault(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
if !strings.HasPrefix(server.baseURL, "http://127.0.0.1:") {
t.Fatalf("listen address = %q", server.baseURL)
}
server.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`)
}
func TestPreboundListenerHasOneStartupAndShutdownOwner(t *testing.T) {
for _, serve := range []bool{false, true} {
t.Run(fmt.Sprintf("serve=%t", serve), func(t *testing.T) {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
address := listener.Addr().(*net.TCPAddr)
server := newServer(t, Options{Listener: listener})
if serve {
port, err := server.Listen(t.Context())
if err != nil || port != address.Port || server.listener != listener {
t.Fatalf("startup replaced the prebound listener: port=%d err=%v", port, err)
}
client := &http.Client{Timeout: time.Second}
response, err := client.Get("http://" + address.String() + "/healthz")
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("prebound server health = %d", response.StatusCode)
}
}
if err := server.Close(t.Context()); err != nil {
t.Fatal(err)
}
rebound, err := net.ListenTCP("tcp4", address)
if err != nil {
t.Fatalf("prebound listener survived shutdown: %v", err)
}
_ = rebound.Close()
})
}
}
func TestReportsProcessLivenessWithoutAccessChecks(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/healthz", withOrigin("https://foreign.test")).
expect(http.StatusOK, `{"status":"ok"}`).
expectHeader("Cache-Control", "no-store").
expectHeader("Content-Type", "application/json; charset=utf-8")
}
func TestOnlyAcceptsGETHealthChecks(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodPost, "/healthz").
expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`).
expectHeader("Allow", "GET").
expectHeader("Cache-Control", "no-store")
}
func TestListenIsSingleShotAndRefusedAfterClose(t *testing.T) {
before := runtime.NumGoroutine()
server := start(t, Options{Config: testConfig(t)})
if _, err := server.Listen(context.Background()); err == nil ||
err.Error() != "Screener server startup was already requested" {
t.Fatalf("second Listen error = %v", err)
}
if err := server.Close(context.Background()); err != nil {
t.Fatalf("Close: %v", err)
}
expectGoroutinesSettled(t, before)
fresh := newServer(t, Options{Config: testConfig(t)})
if err := fresh.Close(context.Background()); err != nil {
t.Fatalf("Close before Listen: %v", err)
}
if _, err := fresh.Listen(context.Background()); err == nil ||
err.Error() != "Screener server is closing" {
t.Fatalf("Listen after Close error = %v", err)
}
}
// --- request failures and server settings ---------------------------------
// The createServer .catch of app.ts: a handler that throws answers 500 and the
// record carries fixed method and route categories without request data.
func TestRequestFailureAnswers500AndLogsRequestCategoriesOnly(t *testing.T) {
var logged bytes.Buffer
server := newServer(t, Options{
Config: testConfig(t),
Logger: slog.New(slog.NewTextHandler(&logged, nil)),
})
server.frontend = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("viewer-grant-that-must-not-be-logged")
})
server.acceptingTraffic.Store(true)
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder,
httptest.NewRequest(http.MethodGet, "/viewer-grant-that-must-not-be-logged?grant=viewer-grant-that-must-not-be-logged", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", recorder.Code)
}
if body := recorder.Body.String(); body != `{"error":"Internal server error"}` {
t.Fatalf("body = %s", body)
}
record := logged.String()
if !strings.Contains(record, "HTTP request failed") ||
!strings.Contains(record, "method=GET") ||
!strings.Contains(record, "route=frontend") || strings.Contains(record, "path=") {
t.Fatalf("log record = %q", record)
}
if strings.Contains(record, "viewer-grant-that-must-not-be-logged") {
t.Fatalf("the failure log leaked the request: %q", record)
}
logged.Reset()
server.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest("private-method-token", "/private-path-token", nil))
if record := logged.String(); !strings.Contains(record, "method=other") || strings.Contains(record, "private-") {
t.Fatalf("the failure log leaked a custom method: %q", record)
}
}
// A failure after the response started destroyed the socket in Node; in Go the
// equivalent is http.ErrAbortHandler, which net/http turns into a silent close.
func TestRequestFailureAfterTheResponseStartedAbortsTheConnection(t *testing.T) {
server := newServer(t, Options{
Config: testConfig(t),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
server.frontend = http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusOK)
panic("late failure")
})
server.acceptingTraffic.Store(true)
defer func() {
if recovered := recover(); recovered != http.ErrAbortHandler {
t.Fatalf("recovered = %v, want http.ErrAbortHandler", recovered)
}
}()
server.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(http.MethodGet, "/late", nil))
t.Fatal("ServeHTTP returned instead of aborting the connection")
}
// The three Node HTTP server settings of map section 3.3 / D11, and the write
// deadline Node never had.
func TestHTTPServerKeepsTheNodeTimeouts(t *testing.T) {
server := newServer(t, Options{Config: testConfig(t)})
if got := server.httpServer.ReadHeaderTimeout; got != 15*time.Second {
t.Fatalf("ReadHeaderTimeout = %v, want headersTimeout 15s", got)
}
if got := server.httpServer.ReadTimeout; got != 10*time.Second {
t.Fatalf("ReadTimeout = %v, want requestTimeout 10s", got)
}
if got := server.httpServer.IdleTimeout; got != 5*time.Second {
t.Fatalf("IdleTimeout = %v, want keepAliveTimeout 5s", got)
}
if got := server.httpServer.WriteTimeout; got != 0 {
t.Fatalf("WriteTimeout = %v, want none", got)
}
}
// The upgrade dispatch of ServeHTTP: Node handed every Connection: Upgrade
// request to the signaling server's own listener, which owns the whole
// rejection ladder, and a plain GET of the same path stayed an ordinary
// request. It is also the only cover for the response wrapper being
// unwrappable to the http.Hijacker websocket.Accept needs.
func TestSignalUpgradeIsDispatchedToTheSignalingServer(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// websocket.Dial refuses a client with a Timeout, so the harness client
// cannot be reused here.
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
target := "ws" + strings.TrimPrefix(server.baseURL, "http") + "/signal"
connection, _, err := websocket.Dial(ctx, target, &websocket.DialOptions{
HTTPClient: client,
HTTPHeader: http.Header{"Origin": []string{allowedOrigin}},
})
if err != nil {
t.Fatalf("upgrade /signal: %v", err)
}
if err := connection.Close(websocket.StatusNormalClosure, ""); err != nil {
t.Fatalf("close signal connection: %v", err)
}
if _, _, err := websocket.Dial(ctx, target, &websocket.DialOptions{
HTTPClient: client,
HTTPHeader: http.Header{"Origin": []string{"https://foreign.test"}},
}); err == nil {
t.Fatal("a foreign origin completed the upgrade")
}
server.do(http.MethodGet, "/signal").
expect(http.StatusNotFound, `{"error":"Not found"}`)
}
// --- startup failures the TypeScript could not have ------------------------
// panickingRoomControl is a reconciliation step that fails with a panic rather
// than an error, which is what a nil dereference inside a future startup step
// would look like.
// app.ts:219 awaited startupOperation inside a try/catch, so a rejected
// startup still settled the promise shutdown waited on. A panic out of start()
// must release the same latch.
// syncBuffer collects log records written from the Serve goroutine.
type syncBuffer struct {
mu sync.Mutex
records bytes.Buffer
}
func (buffer *syncBuffer) Write(record []byte) (int, error) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.records.Write(record)
}
func (buffer *syncBuffer) String() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.records.String()
}
// Node ended the process when the listening server raised, because
// bindHttpServer had already removed its "error" handler. Serve's error must
// at least reach the operator instead of leaving nothing serving in silence.
func TestReportsAListenerThatDiesWhileServing(t *testing.T) {
var logged syncBuffer
server := newServer(t, Options{
Config: testConfig(t),
Logger: slog.New(slog.NewTextHandler(&logged, nil)),
})
if _, err := server.Listen(context.Background()); err != nil {
t.Fatalf("Listen: %v", err)
}
// Out of band: no Shutdown, so Serve returns a real accept failure.
if err := server.listener.Close(); err != nil {
t.Fatalf("close the listener: %v", err)
}
waitFor(t, "the serve failure to be reported", func() bool {
return strings.Contains(logged.String(), "Screener HTTP server stopped unexpectedly")
})
if record := logged.String(); !strings.Contains(record, "errorType=*net.OpError") || strings.Contains(record, "error=") || strings.Contains(record, "127.0.0.1") {
t.Fatalf("listener failure must log only the error type: %q", record)
}
}