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
This commit is contained in:
1 parent
c9a6642ac1
commit
35f9251088
67 files changed
+1553
-1441
No files matched your search
@@ -66,7 +66,6 @@ type Options struct {
|
||||
AuthenticationTimeoutMs int
|
||||
ViewerDisconnectGraceMs int
|
||||
HeartbeatIntervalMs int
|
||||
CleanupIntervalMs int
|
||||
MaxSignalConnections int
|
||||
MaxUnauthenticatedSignalConnections int
|
||||
SiteAccessTTLSeconds int
|
||||
@@ -125,7 +124,7 @@ func New(options Options) (*Server, error) {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
store, err := newRoomStore(options, now)
|
||||
store, err := newRoomStore(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,7 +170,6 @@ func New(options Options) (*Server, error) {
|
||||
AuthenticationTimeoutMs: options.AuthenticationTimeoutMs,
|
||||
ViewerDisconnectGraceMs: options.ViewerDisconnectGraceMs,
|
||||
HeartbeatIntervalMs: options.HeartbeatIntervalMs,
|
||||
CleanupIntervalMs: options.CleanupIntervalMs,
|
||||
MaxConnections: options.MaxSignalConnections,
|
||||
MaxUnauthenticatedConnections: options.MaxUnauthenticatedSignalConnections,
|
||||
AfterFunc: options.AfterFunc,
|
||||
@@ -189,7 +187,7 @@ func New(options Options) (*Server, error) {
|
||||
|
||||
// newRoomStore builds the room store createScreenerServer would otherwise take
|
||||
// from Options.RoomStore. An empty RoomDatabasePath is the memory-only mode.
|
||||
func newRoomStore(options Options, now func() int64) (*room.Store, error) {
|
||||
func newRoomStore(options Options) (*room.Store, error) {
|
||||
if options.RoomStore != nil {
|
||||
return options.RoomStore, nil
|
||||
}
|
||||
@@ -204,11 +202,9 @@ func newRoomStore(options Options, now func() int64) (*room.Store, error) {
|
||||
database = opened
|
||||
}
|
||||
return room.New(room.Options{
|
||||
LeaseMs: options.Config.RoomLeaseMs,
|
||||
MaxRooms: room.Capacity,
|
||||
MaxViewersPerRoom: options.Config.MaxViewersPerRoom,
|
||||
Database: database,
|
||||
Now: now,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ func testConfig(t *testing.T) config.Config {
|
||||
PublicBaseURL: publicBaseURL,
|
||||
AllowedOrigins: map[string]struct{}{allowedOrigin: {}},
|
||||
SiteAccessPassword: testAccessPassword,
|
||||
RoomLeaseMs: 86_400_000,
|
||||
MaxViewersPerRoom: 8,
|
||||
EndpointMediaCopyCapacity: 2,
|
||||
NATPredictionEnabled: false,
|
||||
@@ -66,7 +65,7 @@ func testConfig(t *testing.T) config.Config {
|
||||
}
|
||||
|
||||
// newServer builds a server without listening. It mirrors the TS harness: the
|
||||
// heartbeat and cleanup intervals are pushed out of the way and a fake room
|
||||
// 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()
|
||||
@@ -76,9 +75,6 @@ func newServer(t *testing.T, options Options) *Server {
|
||||
if options.HeartbeatIntervalMs == 0 {
|
||||
options.HeartbeatIntervalMs = 60_000
|
||||
}
|
||||
if options.CleanupIntervalMs == 0 {
|
||||
options.CleanupIntervalMs = 60_000
|
||||
}
|
||||
server, err := New(options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
@@ -734,18 +730,12 @@ func TestRoomCreationIssuesAnIndependentFragmentOnlyViewerGrant(t *testing.T) {
|
||||
if created.CodeEntryPolicy != "open" {
|
||||
t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy)
|
||||
}
|
||||
if created.ExpiresAt == nil || *created.ExpiresAt == "" {
|
||||
t.Fatal("expiresAt is missing")
|
||||
}
|
||||
if created.RoomLeaseSeconds != 86_400 {
|
||||
t.Fatalf("roomLeaseSeconds = %d", created.RoomLeaseSeconds)
|
||||
}
|
||||
// The response carries no viewer grant expiry and no ICE configuration.
|
||||
// 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{"viewerGrantExpiresAt", "iceConfig", "viewerGrant"} {
|
||||
for _, absent := range []string{"iceConfig", "viewerGrant"} {
|
||||
if _, present := raw[absent]; present {
|
||||
t.Fatalf("response exposes %q", absent)
|
||||
}
|
||||
@@ -818,16 +808,10 @@ func TestRoomCreationAllocatesUniqueFourDigitCodesConcurrently(t *testing.T) {
|
||||
if created[0].RoomID == created[1].RoomID {
|
||||
t.Fatalf("both rooms took the code %q", created[0].RoomID)
|
||||
}
|
||||
for _, room := range created {
|
||||
if room.ExpiresAt == nil {
|
||||
t.Fatal("expiresAt is null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *testing.T) {
|
||||
configuration := testConfig(t)
|
||||
configuration.RoomLeaseMs = 90_000
|
||||
server := start(t, Options{Config: configuration})
|
||||
cookie := server.cookie()
|
||||
|
||||
@@ -839,9 +823,6 @@ func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *t
|
||||
if preferred.RoomID != "4321" {
|
||||
t.Fatalf("preferred roomId = %q", preferred.RoomID)
|
||||
}
|
||||
if preferred.RoomLeaseSeconds != 90 {
|
||||
t.Fatalf("roomLeaseSeconds = %d", preferred.RoomLeaseSeconds)
|
||||
}
|
||||
if fallback.RoomID == "4321" {
|
||||
t.Fatal("the occupied preferred code was handed out twice")
|
||||
}
|
||||
@@ -933,7 +914,6 @@ func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) {
|
||||
{"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 expired", replacementFailure, &room.Error{Code: room.CodeRoomExpired}, 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},
|
||||
@@ -965,10 +945,9 @@ func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomAccessManagesADormantRoomWithoutStartingSharingOrRenewing(t *testing.T) {
|
||||
func TestRoomAccessManagesADormantRoomWithoutStartingSharing(t *testing.T) {
|
||||
var now atomic.Int64
|
||||
configuration := testConfig(t)
|
||||
configuration.RoomLeaseMs = 1_000
|
||||
server := start(t, Options{Config: configuration, Now: now.Load})
|
||||
cookie := server.cookie()
|
||||
created := server.createRoom(roomRequest{cookie: cookie}).
|
||||
@@ -1024,20 +1003,11 @@ func TestRoomAccessManagesADormantRoomWithoutStartingSharingOrRenewing(t *testin
|
||||
t.Fatal("managing access started a sharing session")
|
||||
}
|
||||
|
||||
now.Store(1_001)
|
||||
expired, err := server.Store().ExpireRooms(1_001)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireRooms: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, closed := range expired {
|
||||
if closed.RoomID == created.RoomID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("access management renewed the lease; expired = %v", expired)
|
||||
}
|
||||
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) {
|
||||
@@ -1112,7 +1082,7 @@ func TestRoomCreationReturnsServiceUnavailableAtTheGlobalRoomBound(t *testing.T)
|
||||
configuration := testConfig(t)
|
||||
configuration.SiteAccessPassword = ""
|
||||
store, err := room.New(room.Options{
|
||||
LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 8,
|
||||
MaxRooms: 1, MaxViewersPerRoom: 8,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("room.New: %v", err)
|
||||
@@ -1173,7 +1143,6 @@ func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
|
||||
configuration := testConfig(t)
|
||||
configuration.SiteAccessPassword = ""
|
||||
configuration.RoomDatabasePath = databasePath
|
||||
configuration.RoomLeaseMs = 1_000
|
||||
|
||||
first := start(t, Options{Config: configuration, Now: now.Load})
|
||||
created := first.createRoom(roomRequest{codeEntryPolicy: "private"}).
|
||||
@@ -1182,7 +1151,7 @@ func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
|
||||
t.Fatalf("close first: %v", err)
|
||||
}
|
||||
|
||||
now.Store(200)
|
||||
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)
|
||||
|
||||
@@ -371,10 +371,6 @@ func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoo
|
||||
HostToken: created.HostToken,
|
||||
InviteURL: signal.InviteURL(s.config.PublicBaseURL, created.RoomID, created.ViewerGrant),
|
||||
CodeEntryPolicy: created.CodeEntryPolicy,
|
||||
ExpiresAt: created.ExpiresAt,
|
||||
// The lease is configured in whole seconds; TS divided by 1000 into a
|
||||
// JS number and the response schema declares an integer.
|
||||
RoomLeaseSeconds: protocol.Int(s.config.RoomLeaseMs / 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +418,7 @@ func replacementFailure(err error) (int, string, bool) {
|
||||
return http.StatusServiceUnavailable, "Room capacity reached", true
|
||||
case room.CodeRoomAccessDenied, room.CodeRoomBusy:
|
||||
return http.StatusServiceUnavailable, "Room replacement unavailable", true
|
||||
case room.CodeInvalidToken, room.CodeRoomNotFound, room.CodeRoomExpired:
|
||||
case room.CodeInvalidToken, room.CodeRoomNotFound:
|
||||
return http.StatusNotFound, "Room not found", true
|
||||
}
|
||||
return 0, "", false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package config ports src/server/config.ts, src/server/local-config.ts and
|
||||
// src/server/ice.ts: the Hosted environment contract, the Client's local
|
||||
// composition, and the ICE configuration derived from either. It performs no
|
||||
// I/O; the caller supplies the environment as a map.
|
||||
// composition, and the ICE configuration derived from either. The caller
|
||||
// supplies the environment as a map; loading opens no runtime resources.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -33,7 +33,6 @@ const (
|
||||
minSiteAccessPasswordBytes = 8
|
||||
maxSiteAccessPasswordBytes = 128
|
||||
defaultMaxViewersPerRoom = 8
|
||||
defaultRoomLeaseSeconds = 86_400
|
||||
defaultPort = 8787
|
||||
maxPort = 65_535
|
||||
)
|
||||
@@ -62,7 +61,8 @@ var removedEnvironmentVariables = []struct{ name, reason string }{
|
||||
{"PEER_ASSISTED_MEDIA", "peer-assisted media is always enabled"},
|
||||
{"HOST_ADMISSION_PASSWORD", "use SITE_ACCESS_PASSWORD"},
|
||||
{"MAX_PEER_RELAY_DOWNSTREAM_EDGES", "use ENDPOINT_MEDIA_COPY_CAPACITY"},
|
||||
{"ROOM_TTL_SECONDS", "use ROOM_LEASE_SECONDS"},
|
||||
{"ROOM_TTL_SECONDS", "rooms do not expire"},
|
||||
{"ROOM_LEASE_SECONDS", "rooms do not expire"},
|
||||
{"ACCESS_PASSWORD", "use SITE_ACCESS_PASSWORD"},
|
||||
{"NODE_ENV", "use SCREENER_ENV"},
|
||||
}
|
||||
@@ -88,7 +88,6 @@ type Config struct {
|
||||
AllowedOrigins map[string]struct{}
|
||||
SiteAccessPassword string
|
||||
RoomDatabasePath string
|
||||
RoomLeaseMs int64
|
||||
MaxViewersPerRoom int
|
||||
EndpointMediaCopyCapacity int
|
||||
SFU *SFUConfig
|
||||
@@ -208,17 +207,10 @@ func Load(env map[string]string) (Config, error) {
|
||||
return Config{}, errors.New("STUN is required in production")
|
||||
}
|
||||
|
||||
// ALLOWED_ORIGINS and ROOM_LEASE_SECONDS are validated inside the returned
|
||||
// object literal, i.e. after every check above; keep that order.
|
||||
allowedOrigins, err := parseOrigins(env["ALLOWED_ORIGINS"], Origin(publicBaseURL))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
roomLeaseSeconds, err := parsePositiveInteger(env["ROOM_LEASE_SECONDS"],
|
||||
defaultRoomLeaseSeconds, "ROOM_LEASE_SECONDS")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
Env: environment,
|
||||
@@ -228,7 +220,6 @@ func Load(env map[string]string) (Config, error) {
|
||||
AllowedOrigins: allowedOrigins,
|
||||
SiteAccessPassword: siteAccessPassword,
|
||||
RoomDatabasePath: roomDatabasePath,
|
||||
RoomLeaseMs: roomLeaseSeconds * 1_000,
|
||||
MaxViewersPerRoom: int(maxViewersPerRoom),
|
||||
EndpointMediaCopyCapacity: int(endpointMediaCopyCapacity),
|
||||
SFU: sfu,
|
||||
@@ -288,16 +279,18 @@ func parseBoundedInteger(value string, fallback int64, name string, minimum, max
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseRoomDatabasePath ports parseRoomDatabasePath. DECISIONS D8: absoluteness
|
||||
// is filepath.IsAbs, which differs from Node's path.isAbsolute on Windows,
|
||||
// where "/var/lib/screener/rooms.sqlite" is relative to the current volume and
|
||||
// is therefore rejected here. Hosted runs on Linux.
|
||||
// Hosted defaults to durable room authority in its working directory. Explicit
|
||||
// paths stay absolute so a deployment cannot relocate an existing database
|
||||
// when its working directory changes. :memory: selects the in-memory RoomStore.
|
||||
func parseRoomDatabasePath(value string) (string, error) {
|
||||
path := strings.TrimFunc(value, protocol.IsJSWhitespace)
|
||||
if path == "" {
|
||||
return filepath.Abs("rooms.sqlite")
|
||||
}
|
||||
if path == ":memory:" {
|
||||
return "", nil
|
||||
}
|
||||
if path == ":memory:" || strings.ContainsRune(path, 0) || !filepath.IsAbs(path) {
|
||||
if strings.ContainsRune(path, 0) || !filepath.IsAbs(path) {
|
||||
return "", errors.New("ROOM_DATABASE_PATH must be an absolute file path")
|
||||
}
|
||||
return path, nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -64,7 +65,11 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
|
||||
if !reflect.DeepEqual(config.AllowedOrigins, want) {
|
||||
t.Errorf("AllowedOrigins = %v", config.AllowedOrigins)
|
||||
}
|
||||
if config.RoomDatabasePath != "" {
|
||||
defaultDatabasePath, err := filepath.Abs("rooms.sqlite")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.RoomDatabasePath != defaultDatabasePath {
|
||||
t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath)
|
||||
}
|
||||
if len(config.STUNURLs) != 0 {
|
||||
@@ -88,14 +93,35 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
|
||||
if config.SFU != nil {
|
||||
t.Errorf("SFU = %+v", config.SFU)
|
||||
}
|
||||
if config.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
|
||||
}
|
||||
if config.SiteAccessPassword != "" {
|
||||
t.Error("SiteAccessPassword is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRoomStorage(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
t.Chdir(directory)
|
||||
for _, base := range []map[string]string{nil, productionBase} {
|
||||
for _, override := range []map[string]string{nil,
|
||||
{"ROOM_DATABASE_PATH": ""}, {"ROOM_DATABASE_PATH": " \t"},
|
||||
{"ROOM_DATABASE_PATH": ":memory:"},
|
||||
} {
|
||||
configuration := mustLoad(t, env(base, override))
|
||||
want := filepath.Join(directory, "rooms.sqlite")
|
||||
if override["ROOM_DATABASE_PATH"] == ":memory:" {
|
||||
want = ""
|
||||
}
|
||||
if configuration.RoomDatabasePath != want {
|
||||
t.Errorf("Load(%v): RoomDatabasePath = %q, want %q", override, configuration.RoomDatabasePath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("configuration loading created runtime files: %v, %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSFU(t *testing.T) {
|
||||
config := mustLoad(t, map[string]string{
|
||||
"LISTEN_HOST": "127.0.0.1",
|
||||
@@ -231,17 +257,6 @@ func TestLoadAccepts(t *testing.T) {
|
||||
t.Error("NATPredictionEnabled = false")
|
||||
}
|
||||
}},
|
||||
{"room lease default", map[string]string{}, func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
{"room lease override", map[string]string{"ROOM_LEASE_SECONDS": "3600"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 3_600_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
{"file-backed room authority in development",
|
||||
map[string]string{"ROOM_DATABASE_PATH": " " + developmentPath + " "},
|
||||
func(t *testing.T, c Config) {
|
||||
@@ -249,12 +264,6 @@ func TestLoadAccepts(t *testing.T) {
|
||||
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
|
||||
}
|
||||
}},
|
||||
{"blank room database path", map[string]string{"ROOM_DATABASE_PATH": ""},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomDatabasePath != "" {
|
||||
t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath)
|
||||
}
|
||||
}},
|
||||
{"file-backed room authority in production",
|
||||
env(productionBase, map[string]string{"ROOM_DATABASE_PATH": productionPath}),
|
||||
func(t *testing.T, c Config) {
|
||||
@@ -282,12 +291,6 @@ func TestLoadAccepts(t *testing.T) {
|
||||
t.Errorf("Port = %d", c.Port)
|
||||
}
|
||||
}},
|
||||
{"exponent room lease", map[string]string{"ROOM_LEASE_SECONDS": "1e3"},
|
||||
func(t *testing.T, c Config) {
|
||||
if c.RoomLeaseMs != 1_000_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs)
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
@@ -340,8 +343,6 @@ func TestLoadRejects(t *testing.T) {
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY must be between 1 and 3"},
|
||||
{"fractional copy capacity", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1.5"},
|
||||
"ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"},
|
||||
{"zero room lease", map[string]string{"ROOM_LEASE_SECONDS": "0"},
|
||||
"ROOM_LEASE_SECONDS must be a positive integer"},
|
||||
{"port zero", map[string]string{"PORT": "0"}, "PORT must be a positive integer"},
|
||||
{"port not a number", map[string]string{"PORT": "eight"}, "PORT must be a positive integer"},
|
||||
{"port above the TCP ceiling", map[string]string{"PORT": "65536"}, "PORT must be at most 65535"},
|
||||
@@ -434,8 +435,6 @@ func TestLoadRejects(t *testing.T) {
|
||||
"NAT_PREDICTION_ENABLED must be true or false"},
|
||||
|
||||
// Room database path.
|
||||
{"memory room database path", map[string]string{"ROOM_DATABASE_PATH": ":memory:"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
{"room database path with a NUL", map[string]string{"ROOM_DATABASE_PATH": "rooms\x00.sqlite"},
|
||||
"ROOM_DATABASE_PATH must be an absolute file path"},
|
||||
{"bare room database file name", map[string]string{"ROOM_DATABASE_PATH": "rooms.sqlite"},
|
||||
@@ -461,7 +460,11 @@ func TestLoadRejects(t *testing.T) {
|
||||
{"removed peer-assisted toggle", map[string]string{"PEER_ASSISTED_MEDIA": "true"},
|
||||
"PEER_ASSISTED_MEDIA is no longer supported; peer-assisted media is always enabled"},
|
||||
{"removed room TTL", map[string]string{"ROOM_TTL_SECONDS": ""},
|
||||
"ROOM_TTL_SECONDS is no longer supported; use ROOM_LEASE_SECONDS"},
|
||||
"ROOM_TTL_SECONDS is no longer supported; rooms do not expire"},
|
||||
{"removed room lease", map[string]string{"ROOM_LEASE_SECONDS": "86400"},
|
||||
"ROOM_LEASE_SECONDS is no longer supported; rooms do not expire"},
|
||||
{"blank removed room lease", map[string]string{"ROOM_LEASE_SECONDS": ""},
|
||||
"ROOM_LEASE_SECONDS is no longer supported; rooms do not expire"},
|
||||
{"removed access password", map[string]string{"ACCESS_PASSWORD": "legacy-password"},
|
||||
"ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"},
|
||||
{"blank access password", map[string]string{"ACCESS_PASSWORD": ""},
|
||||
|
||||
@@ -12,9 +12,6 @@ import (
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// Ported from src/server/local-config.ts.
|
||||
const localRoomLeaseMs = 24 * 60 * 60 * 1_000
|
||||
|
||||
// localPasswordPattern is VISIBLE_ASCII_PATTERN from local-config.ts. Because
|
||||
// it admits one byte per character the TypeScript's extra Buffer.byteLength
|
||||
// bounds are already implied by {8,128}.
|
||||
@@ -109,7 +106,6 @@ func Local(options LocalOptions) (Config, error) {
|
||||
PublicBaseURL: publicBaseURL,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
SiteAccessPassword: siteAccessPassword,
|
||||
RoomLeaseMs: localRoomLeaseMs,
|
||||
MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit,
|
||||
EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity,
|
||||
STUNURLs: stunURLs,
|
||||
|
||||
@@ -40,9 +40,6 @@ func TestLocalMapsClientInputs(t *testing.T) {
|
||||
if config.SiteAccessPassword != "local-access-password" {
|
||||
t.Errorf("SiteAccessPassword = %q", config.SiteAccessPassword)
|
||||
}
|
||||
if config.RoomLeaseMs != 86_400_000 {
|
||||
t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs)
|
||||
}
|
||||
if config.MaxViewersPerRoom != 20 {
|
||||
t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
MaxViewersPerRoomLimit = 20
|
||||
MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1
|
||||
MaxSignalBytes = 64 * 1024
|
||||
SignalingProtocol = "screener-v22"
|
||||
SignalingProtocol = "screener-v23"
|
||||
RoomCodeLength = 4
|
||||
MaxMediaRouteRevision = MaxSafeInteger
|
||||
MaxIceServerURLs = 8
|
||||
|
||||
@@ -75,9 +75,8 @@ const (
|
||||
func authenticatedHost() []member {
|
||||
return []member{
|
||||
{"type", `"authenticated"`},
|
||||
{"protocol", `"screener-v22"`},
|
||||
{"protocol", `"screener-v23"`},
|
||||
{"peerId", `"host_12345678"`},
|
||||
{"roomExpiresAt", `"2026-08-18T18:00:00.000Z"`},
|
||||
{"maxViewers", `8`},
|
||||
{"endpointMediaCopyCapacity", `2`},
|
||||
{"hostOnline", `true`},
|
||||
@@ -164,10 +163,7 @@ func TestAuthenticatedPasswordStateIsHostScoped(t *testing.T) {
|
||||
object(with(viewer, member{"hostPaused", `true`})), true)
|
||||
}
|
||||
|
||||
// "represents persistent rooms without a room expiry" and the revision bound.
|
||||
func TestAuthenticatedRoomLifetimeAndRevision(t *testing.T) {
|
||||
assertServerMessage(t, "no expiry",
|
||||
object(with(authenticatedHost(), member{"roomExpiresAt", `null`})), true)
|
||||
func TestAuthenticatedRoomRevision(t *testing.T) {
|
||||
assertServerMessage(t, "revision at the ceiling",
|
||||
object(with(authenticatedHost(), member{"routeRevision", `9007199254740991`})), true)
|
||||
assertServerMessage(t, "revision past the ceiling",
|
||||
@@ -584,7 +580,7 @@ func TestAuthenticatedKeyOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
"type", "protocol", "peerId", "roomExpiresAt", "maxViewers",
|
||||
"type", "protocol", "peerId", "maxViewers",
|
||||
"endpointMediaCopyCapacity", "hostOnline", "hostPaused", "connectionId",
|
||||
"iceConfig", "routePolicy", "codeEntryPolicy", "viewerAuthorizationGeneration",
|
||||
"mediaMode", "shareGeneration", "routeRevision", "routeAssignment",
|
||||
@@ -622,7 +618,7 @@ func TestOptionalAndNullableAreExact(t *testing.T) {
|
||||
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
|
||||
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
|
||||
message, err := DecodeClientMessage([]byte(
|
||||
`{"type":"authenticate","protocol":"screener-v22","roomId":"1234","role":"host",` +
|
||||
`{"type":"authenticate","protocol":"screener-v23","roomId":"1234","role":"host",` +
|
||||
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -158,12 +158,10 @@ func DecodeRoomAccessUpdateRequest(data []byte) (RoomAccessUpdateRequest, error)
|
||||
|
||||
// CreateRoomResponse mirrors createRoomResponseSchema.
|
||||
type CreateRoomResponse struct {
|
||||
RoomID string `json:"roomId"`
|
||||
HostToken string `json:"hostToken"`
|
||||
InviteURL string `json:"inviteUrl"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
RoomLeaseSeconds Int `json:"roomLeaseSeconds"`
|
||||
RoomID string `json:"roomId"`
|
||||
HostToken string `json:"hostToken"`
|
||||
InviteURL string `json:"inviteUrl"`
|
||||
CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"`
|
||||
}
|
||||
|
||||
// RoomAccessUpdateResponse is one member of roomAccessUpdateResponseSchema.
|
||||
|
||||
@@ -77,16 +77,6 @@ func ValidViewerPassword(value string) bool {
|
||||
viewerPasswordPattern.MatchString(value)
|
||||
}
|
||||
|
||||
// isoDateTimePattern mirrors z.string().datetime(): an RFC 3339 UTC instant
|
||||
// with a "Z" designator and no offset.
|
||||
var isoDateTimePattern = regexp.MustCompile(
|
||||
`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`)
|
||||
|
||||
// ValidISODateTime reports whether value is a zod `.datetime()` string.
|
||||
func ValidISODateTime(value string) bool {
|
||||
return isoDateTimePattern.MatchString(value)
|
||||
}
|
||||
|
||||
// forbiddenDisplayNameCharacters is FORBIDDEN_DISPLAY_NAME_CHARACTERS from
|
||||
// src/shared/protocol.ts.
|
||||
var forbiddenDisplayNameCharacters = regexp.MustCompile(
|
||||
|
||||
@@ -19,7 +19,6 @@ type AuthenticatedHostMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
PeerID string `json:"peerId"`
|
||||
RoomExpiresAt *string `json:"roomExpiresAt"`
|
||||
MaxViewers Int `json:"maxViewers"`
|
||||
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
|
||||
HostOnline bool `json:"hostOnline"`
|
||||
@@ -44,7 +43,6 @@ type AuthenticatedViewerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
PeerID string `json:"peerId"`
|
||||
RoomExpiresAt *string `json:"roomExpiresAt"`
|
||||
MaxViewers Int `json:"maxViewers"`
|
||||
EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"`
|
||||
HostOnline bool `json:"hostOnline"`
|
||||
@@ -294,7 +292,7 @@ func decodeEmptyServerMessage(data []byte, message ServerMessage) (ServerMessage
|
||||
|
||||
func validateAuthenticatedBase(
|
||||
present fields,
|
||||
protocol string, peerID string, roomExpiresAt *string,
|
||||
protocol string, peerID string,
|
||||
maxViewers, endpointCapacity Int, connectionID *string,
|
||||
codeEntryPolicy, viewerAuthorizationGeneration, mediaMode string,
|
||||
shareGeneration *string, routeRevision Int,
|
||||
@@ -306,7 +304,7 @@ func validateAuthenticatedBase(
|
||||
return err
|
||||
}
|
||||
if err := present.requireNullable(
|
||||
"roomExpiresAt", "connectionId", "shareGeneration"); err != nil {
|
||||
"connectionId", "shareGeneration"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := present.optional("hostPaused", "routePolicy"); err != nil {
|
||||
@@ -318,9 +316,6 @@ func validateAuthenticatedBase(
|
||||
if !ValidOpaqueID(peerID) {
|
||||
return errors.New("peerId is not an opaque id")
|
||||
}
|
||||
if roomExpiresAt != nil && !ValidISODateTime(*roomExpiresAt) {
|
||||
return errors.New("roomExpiresAt is not an ISO instant")
|
||||
}
|
||||
if !inRangeInt(maxViewers, 1, MaxViewersPerRoomLimit) {
|
||||
return errors.New("maxViewers is out of range")
|
||||
}
|
||||
@@ -364,7 +359,7 @@ func decodeAuthenticated(data []byte) (ServerMessage, error) {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
|
||||
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.ConnectionID, message.CodeEntryPolicy,
|
||||
message.ViewerAuthorizationGeneration, message.MediaMode,
|
||||
message.ShareGeneration, message.RouteRevision); err != nil {
|
||||
@@ -381,7 +376,7 @@ func decodeAuthenticated(data []byte) (ServerMessage, error) {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID,
|
||||
message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.MaxViewers, message.EndpointMediaCopyCapacity,
|
||||
message.ConnectionID, message.CodeEntryPolicy,
|
||||
message.ViewerAuthorizationGeneration, message.MediaMode,
|
||||
message.ShareGeneration, message.RouteRevision); err != nil {
|
||||
@@ -653,7 +648,7 @@ func decodeRoomClosed(data []byte) (ServerMessage, error) {
|
||||
if err := present.require("type", "reason"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enumOf(message.Reason, "host-ended", "expired") {
|
||||
if message.Reason != "host-ended" {
|
||||
return nil, fmt.Errorf("unknown room-closed reason %q", message.Reason)
|
||||
}
|
||||
return message, nil
|
||||
|
||||
@@ -45,7 +45,7 @@ var (
|
||||
routeDiagnosticStages = []string{"admission", "first-frame", "quality-proof"}
|
||||
senderQualityStates = []string{"unknown", "healthy", "degraded"}
|
||||
senderQualityDegradedReasons = []string{"none", "bandwidth", "cpu"}
|
||||
errorCodes = []string{"AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", "ROOM_NOT_FOUND", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR"}
|
||||
errorCodes = []string{"AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", "ROOM_NOT_FOUND", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR"}
|
||||
)
|
||||
|
||||
func validRevision(revision Int) bool {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// testdata/node-rooms.db was written once by node:sqlite (DatabaseSync) with
|
||||
// the schema of src/server/room-database.ts and two synthetic rooms: 4321 is
|
||||
// active (NULL lease) with a 48-byte password material, 5678 is dormant with a
|
||||
// lease of 1000 ms. No real token, grant or password is involved. The extension
|
||||
// is .db because .gitignore excludes *.sqlite repository-wide.
|
||||
func copyNodeFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
fixture, err := os.ReadFile(filepath.Join("testdata", "node-rooms.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "rooms.sqlite")
|
||||
if err := os.WriteFile(path, fixture, 0o600); err != nil {
|
||||
t.Fatalf("write fixture copy: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func openFixture(t *testing.T, startupNowMs, activeLeaseExpiresAtMs int64) []StoredRoomAuthority {
|
||||
t.Helper()
|
||||
database, err := NewDatabase(copyNodeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
restored, err := database.Initialize(startupNowMs, activeLeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func TestRecoversANodeWrittenDatabase(t *testing.T) {
|
||||
restored := openFixture(t, 5_000, 6_000)
|
||||
if len(restored) != 1 {
|
||||
t.Fatalf("restored %d rooms, want 1 (the dormant room was due at 1000)", len(restored))
|
||||
}
|
||||
room := restored[0]
|
||||
if room.RoomID != "4321" || room.CodeEntryPolicy != protocol.CodeEntryPrivate {
|
||||
t.Fatalf("restored %+v", room)
|
||||
}
|
||||
if room.LeaseExpiresAtMs == nil || *room.LeaseExpiresAtMs != 6_000 {
|
||||
t.Fatalf("active room lease = %v, want the startup lease 6000", room.LeaseExpiresAtMs)
|
||||
}
|
||||
if !bytes.Equal(room.HostTokenDigest, bytes.Repeat([]byte{0x11}, 32)) ||
|
||||
!bytes.Equal(room.ViewerGrantDigest, bytes.Repeat([]byte{0x22}, 32)) {
|
||||
t.Fatal("digests did not survive the Node round trip")
|
||||
}
|
||||
if room.ViewerAuthorizationGeneration != "MzMzMzMzMzMzMzMzMzMzMw" {
|
||||
t.Fatalf("generation = %q", room.ViewerAuthorizationGeneration)
|
||||
}
|
||||
if !bytes.Equal(room.ViewerPasswordMaterial, bytes.Repeat([]byte{0x44}, 48)) {
|
||||
t.Fatalf("password material = %d bytes", len(room.ViewerPasswordMaterial))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepsADormantNodeWrittenDeadline(t *testing.T) {
|
||||
restored := openFixture(t, 500, 1_500)
|
||||
if len(restored) != 2 {
|
||||
t.Fatalf("restored %d rooms, want 2", len(restored))
|
||||
}
|
||||
// readStoredRooms orders by room_id.
|
||||
if restored[0].RoomID != "4321" || restored[0].LeaseExpiresAtMs == nil ||
|
||||
*restored[0].LeaseExpiresAtMs != 1_500 {
|
||||
t.Fatalf("active room = %+v", restored[0])
|
||||
}
|
||||
if restored[1].RoomID != "5678" || restored[1].LeaseExpiresAtMs == nil ||
|
||||
*restored[1].LeaseExpiresAtMs != 1_000 {
|
||||
t.Fatalf("dormant room = %+v", restored[1])
|
||||
}
|
||||
if restored[1].ViewerPasswordMaterial != nil {
|
||||
t.Fatalf("dormant room material = %v, want nil", restored[1].ViewerPasswordMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRestoresANodeWrittenDatabase(t *testing.T) {
|
||||
testClock := &clock{nowMs: 5_000}
|
||||
store := stableStore(t, copyNodeFixture(t), testClock, Options{LeaseMs: 1_000})
|
||||
if store.Size() != 1 {
|
||||
t.Fatalf("size = %d, want 1", store.Size())
|
||||
}
|
||||
// The recovered room holds the startup lease: 5000 + 1000.
|
||||
testClock.nowMs = 6_001
|
||||
expired, err := store.ExpireRooms(testClock.nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireRooms: %v", err)
|
||||
}
|
||||
if len(expired) != 1 || expired[0].RoomID != "4321" {
|
||||
t.Fatalf("expired = %+v", expired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaTextMatchesTheNodeFixture proves the CREATE TABLE text this package
|
||||
// executes is byte for byte the one src/server/room-database.ts executed.
|
||||
func TestSchemaTextMatchesTheNodeFixture(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
created := readSchemaText(t, path)
|
||||
fixture := readSchemaText(t, copyNodeFixture(t))
|
||||
if created.sql != fixture.sql {
|
||||
t.Fatalf("schema text differs\n go: %q\n node: %q", created.sql, fixture.sql)
|
||||
}
|
||||
if created.applicationID != fixture.applicationID ||
|
||||
created.applicationID != roomDatabaseApplicationID {
|
||||
t.Fatalf("application_id go=%d node=%d", created.applicationID, fixture.applicationID)
|
||||
}
|
||||
if created.userVersion != fixture.userVersion ||
|
||||
created.userVersion != roomDatabaseSchemaVersion {
|
||||
t.Fatalf("user_version go=%d node=%d", created.userVersion, fixture.userVersion)
|
||||
}
|
||||
}
|
||||
|
||||
type schemaText struct {
|
||||
sql string
|
||||
applicationID int64
|
||||
userVersion int64
|
||||
}
|
||||
|
||||
func readSchemaText(t *testing.T, path string) schemaText {
|
||||
t.Helper()
|
||||
raw := rawDatabase(t, path)
|
||||
var text schemaText
|
||||
if err := raw.QueryRow(
|
||||
"SELECT sql FROM sqlite_schema WHERE name = 'rooms'").Scan(&text.sql); err != nil {
|
||||
t.Fatalf("read schema: %v", err)
|
||||
}
|
||||
if err := raw.QueryRow("PRAGMA application_id").Scan(&text.applicationID); err != nil {
|
||||
t.Fatalf("read application_id: %v", err)
|
||||
}
|
||||
if err := raw.QueryRow("PRAGMA user_version").Scan(&text.userVersion); err != nil {
|
||||
t.Fatalf("read user_version: %v", err)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -15,18 +15,14 @@ import (
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
// roomDatabaseApplicationID and roomDatabaseSchemaVersion are the
|
||||
// APPLICATION_ID / SCHEMA_VERSION of src/server/room-database.ts, checked on
|
||||
// open so another SQLite file cannot be adopted as the room authority. The ID
|
||||
// is ASCII "SCRN"; roomSchema below repeats it in decimal because that literal
|
||||
// has to match the TypeScript statement text byte for byte.
|
||||
// Database identity and the single current schema are checked before adopting
|
||||
// room authority. The application ID is ASCII "SCRN".
|
||||
const (
|
||||
roomDatabaseApplicationID = 0x5343524e
|
||||
roomDatabaseSchemaVersion = 1
|
||||
roomDatabaseSchemaVersion = 2
|
||||
)
|
||||
|
||||
// roomSchema is the exact text src/server/room-database.ts executes, so a file
|
||||
// created here and one created by the TypeScript hold the same sqlite_schema.
|
||||
// roomSchema stores durable authority only, never presence or media state.
|
||||
const roomSchema = `
|
||||
CREATE TABLE rooms (
|
||||
room_id TEXT PRIMARY KEY NOT NULL
|
||||
@@ -46,15 +42,10 @@ const roomSchema = `
|
||||
CHECK (
|
||||
viewer_password_material IS NULL OR
|
||||
(typeof(viewer_password_material) = 'blob' AND length(viewer_password_material) = 48)
|
||||
),
|
||||
lease_expires_at_ms INTEGER NULL
|
||||
CHECK (
|
||||
lease_expires_at_ms IS NULL OR
|
||||
(lease_expires_at_ms > 0 AND lease_expires_at_ms <= 9007199254740991)
|
||||
)
|
||||
) STRICT;
|
||||
PRAGMA application_id = 1396920910;
|
||||
PRAGMA user_version = 1;
|
||||
PRAGMA user_version = 2;
|
||||
`
|
||||
|
||||
const selectStoredRooms = `SELECT
|
||||
@@ -63,8 +54,7 @@ const selectStoredRooms = `SELECT
|
||||
viewer_grant_digest,
|
||||
viewer_authorization_generation,
|
||||
code_entry_policy,
|
||||
viewer_password_material,
|
||||
lease_expires_at_ms
|
||||
viewer_password_material
|
||||
FROM rooms
|
||||
ORDER BY room_id`
|
||||
|
||||
@@ -74,14 +64,12 @@ const insertStoredRoom = `INSERT INTO rooms (
|
||||
viewer_grant_digest,
|
||||
viewer_authorization_generation,
|
||||
code_entry_policy,
|
||||
viewer_password_material,
|
||||
lease_expires_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
viewer_password_material
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const deleteRoomStatement = "DELETE FROM rooms WHERE room_id = ? AND host_token_digest = ?"
|
||||
|
||||
// StoredRoomAuthority is StoredRoomAuthority. A nil ViewerPasswordMaterial and
|
||||
// a nil LeaseExpiresAtMs are the TypeScript nulls.
|
||||
// StoredRoomAuthority excludes transient sessions and presence.
|
||||
type StoredRoomAuthority struct {
|
||||
RoomID string
|
||||
HostTokenDigest []byte
|
||||
@@ -89,13 +77,6 @@ type StoredRoomAuthority struct {
|
||||
ViewerAuthorizationGeneration string
|
||||
CodeEntryPolicy protocol.CodeEntryPolicy
|
||||
ViewerPasswordMaterial []byte
|
||||
LeaseExpiresAtMs *int64
|
||||
}
|
||||
|
||||
// RoomIdentity is the {roomId, hostTokenDigest} pair deleteRooms takes.
|
||||
type RoomIdentity struct {
|
||||
RoomID string
|
||||
HostTokenDigest []byte
|
||||
}
|
||||
|
||||
// Database is RoomDatabase: the SQLite stable authority. It holds one
|
||||
@@ -119,25 +100,11 @@ func NewDatabase(path string) (*Database, error) {
|
||||
}
|
||||
|
||||
// Initialize opens the file, takes the exclusive lock, creates or verifies the
|
||||
// schema and recovers the stored rooms: rooms whose lease has passed are
|
||||
// deleted and rooms that were active at shutdown take activeLeaseExpiresAtMs.
|
||||
func (d *Database) Initialize(
|
||||
startupNowMs, activeLeaseExpiresAtMs int64,
|
||||
) (restored []StoredRoomAuthority, err error) {
|
||||
// schema and restores all stored room authority.
|
||||
func (d *Database) Initialize() (restored []StoredRoomAuthority, err error) {
|
||||
if d.conn != nil {
|
||||
return nil, errors.New("Room database is already initialized")
|
||||
}
|
||||
if err := assertTimestamp(startupNowMs, "Room database startup time", true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertTimestamp(
|
||||
activeLeaseExpiresAtMs, "Room database active-room lease deadline", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activeLeaseExpiresAtMs <= startupNowMs {
|
||||
return nil, errors.New(
|
||||
"Room database active-room lease deadline must be in the future")
|
||||
}
|
||||
|
||||
// `new DatabaseSync(this.path, { timeout: 0 })`: a lock conflict must fail
|
||||
// immediately instead of waiting.
|
||||
@@ -174,7 +141,7 @@ func (d *Database) Initialize(
|
||||
if err = d.begin("BEGIN EXCLUSIVE"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restored, err = d.recover(startupNowMs, activeLeaseExpiresAtMs)
|
||||
restored, err = d.recover()
|
||||
if err != nil {
|
||||
d.rollbackIfNeeded()
|
||||
return nil, err
|
||||
@@ -193,63 +160,14 @@ func uriPath(path string) string {
|
||||
return strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F").Replace(filepath.ToSlash(path))
|
||||
}
|
||||
|
||||
func (d *Database) recover(
|
||||
startupNowMs, activeLeaseExpiresAtMs int64,
|
||||
) ([]StoredRoomAuthority, error) {
|
||||
func (d *Database) recover() ([]StoredRoomAuthority, error) {
|
||||
if err := d.ensureExactSchema(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.assertDatabaseIntegrity(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored, err := d.readStoredRooms()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiredCount, activeCount := 0, 0
|
||||
for _, room := range stored {
|
||||
switch {
|
||||
case room.LeaseExpiresAtMs == nil:
|
||||
activeCount++
|
||||
case *room.LeaseExpiresAtMs <= startupNowMs:
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := d.exec(
|
||||
`DELETE FROM rooms
|
||||
WHERE lease_expires_at_ms IS NOT NULL
|
||||
AND lease_expires_at_ms <= ?`, startupNowMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted != int64(expiredCount) {
|
||||
return nil, errors.New("Room database expiry cleanup changed an unexpected row count")
|
||||
}
|
||||
|
||||
converted, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET lease_expires_at_ms = ?
|
||||
WHERE lease_expires_at_ms IS NULL`, activeLeaseExpiresAtMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if converted != int64(activeCount) {
|
||||
return nil, errors.New("Room database active-room recovery changed an unexpected row count")
|
||||
}
|
||||
|
||||
restored := make([]StoredRoomAuthority, 0, len(stored))
|
||||
for _, room := range stored {
|
||||
if room.LeaseExpiresAtMs != nil && *room.LeaseExpiresAtMs <= startupNowMs {
|
||||
continue
|
||||
}
|
||||
if room.LeaseExpiresAtMs == nil {
|
||||
lease := activeLeaseExpiresAtMs
|
||||
room.LeaseExpiresAtMs = &lease
|
||||
}
|
||||
restored = append(restored, room)
|
||||
}
|
||||
return restored, nil
|
||||
return d.readStoredRooms()
|
||||
}
|
||||
|
||||
// InsertRoom is insertRoom.
|
||||
@@ -264,8 +182,7 @@ func (d *Database) InsertRoom(room StoredRoomAuthority) error {
|
||||
room.ViewerGrantDigest,
|
||||
room.ViewerAuthorizationGeneration,
|
||||
room.CodeEntryPolicy,
|
||||
room.ViewerPasswordMaterial,
|
||||
room.LeaseExpiresAtMs)
|
||||
room.ViewerPasswordMaterial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -273,31 +190,6 @@ func (d *Database) InsertRoom(room StoredRoomAuthority) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SetLeaseDeadline is setLeaseDeadline; a nil deadline marks the room active.
|
||||
func (d *Database) SetLeaseDeadline(
|
||||
roomID string, hostTokenDigest []byte, leaseExpiresAtMs *int64,
|
||||
) error {
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
if leaseExpiresAtMs != nil {
|
||||
if err := assertTimestamp(*leaseExpiresAtMs, "Room lease deadline", false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
changes, err := d.exec(
|
||||
`UPDATE rooms
|
||||
SET lease_expires_at_ms = ?
|
||||
WHERE room_id = ? AND host_token_digest = ?`,
|
||||
leaseExpiresAtMs, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return assertSingleChange(changes, "lease update")
|
||||
})
|
||||
}
|
||||
|
||||
// SetViewerPassword is setViewerPassword; nil material clears the password.
|
||||
func (d *Database) SetViewerPassword(
|
||||
roomID string, hostTokenDigest, viewerPasswordMaterial []byte,
|
||||
@@ -374,31 +266,15 @@ func (d *Database) SetViewerGrant(
|
||||
|
||||
// DeleteRoom is deleteRoom.
|
||||
func (d *Database) DeleteRoom(roomID string, hostTokenDigest []byte) error {
|
||||
return d.DeleteRooms([]RoomIdentity{{RoomID: roomID, HostTokenDigest: hostTokenDigest}})
|
||||
}
|
||||
|
||||
// DeleteRooms is deleteRooms: one transaction, and any room whose authority no
|
||||
// longer matches rolls the whole batch back.
|
||||
func (d *Database) DeleteRooms(rooms []RoomIdentity) error {
|
||||
for _, room := range rooms {
|
||||
if err := assertRoomIdentity(room.RoomID, room.HostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(rooms) == 0 {
|
||||
return nil
|
||||
if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.transaction(func() error {
|
||||
for _, room := range rooms {
|
||||
changes, err := d.exec(deleteRoomStatement, room.RoomID, room.HostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertSingleChange(changes, "delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := d.exec(deleteRoomStatement, roomID, hostTokenDigest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return assertSingleChange(changes, "delete")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -423,8 +299,7 @@ func (d *Database) ReplaceRoom(
|
||||
replacement.ViewerGrantDigest,
|
||||
replacement.ViewerAuthorizationGeneration,
|
||||
replacement.CodeEntryPolicy,
|
||||
replacement.ViewerPasswordMaterial,
|
||||
replacement.LeaseExpiresAtMs)
|
||||
replacement.ViewerPasswordMaterial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -548,7 +423,6 @@ func (d *Database) assertExactColumns() error {
|
||||
{"viewer_authorization_generation", "TEXT", 1, 0},
|
||||
{"code_entry_policy", "TEXT", 1, 0},
|
||||
{"viewer_password_material", "BLOB", 0, 0},
|
||||
{"lease_expires_at_ms", "INTEGER", 0, 0},
|
||||
}
|
||||
rows, err := d.conn.QueryContext(context.Background(), "PRAGMA table_info(rooms)")
|
||||
if err != nil {
|
||||
@@ -597,7 +471,6 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
|
||||
var (
|
||||
room StoredRoomAuthority
|
||||
policy string
|
||||
lease sql.NullInt64
|
||||
material []byte
|
||||
)
|
||||
if err := rows.Scan(
|
||||
@@ -606,8 +479,7 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
|
||||
&room.ViewerGrantDigest,
|
||||
&room.ViewerAuthorizationGeneration,
|
||||
&policy,
|
||||
&material,
|
||||
&lease); err != nil {
|
||||
&material); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := readDigest(room.HostTokenDigest, "Host token"); err != nil {
|
||||
@@ -626,12 +498,6 @@ func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) {
|
||||
}
|
||||
room.ViewerPasswordMaterial = material
|
||||
}
|
||||
if lease.Valid {
|
||||
if lease.Int64 <= 0 || lease.Int64 > protocol.MaxSafeInteger {
|
||||
return nil, errors.New("Room database contains an invalid lease deadline")
|
||||
}
|
||||
room.LeaseExpiresAtMs = &lease.Int64
|
||||
}
|
||||
if err := assertStoredRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -721,9 +587,6 @@ func assertStoredRoom(room StoredRoomAuthority) error {
|
||||
if err := assertPasswordMaterial(room.ViewerPasswordMaterial); err != nil {
|
||||
return err
|
||||
}
|
||||
if room.LeaseExpiresAtMs != nil {
|
||||
return assertTimestamp(*room.LeaseExpiresAtMs, "Room database lease deadline", false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -764,16 +627,6 @@ func assertAuthorizationGeneration(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertTimestamp is the TypeScript Number.isSafeInteger guard: SQLite STRICT
|
||||
// accepts an integral REAL and a numeric TEXT in an INTEGER column, so the
|
||||
// range check has to stay on this side.
|
||||
func assertTimestamp(value int64, name string, allowZero bool) error {
|
||||
if value > protocol.MaxSafeInteger || (allowZero && value < 0) || (!allowZero && value <= 0) {
|
||||
return fmt.Errorf("%s must be a safe positive integer", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertSingleChange(changes int64, operation string) error {
|
||||
if changes != 1 {
|
||||
return fmt.Errorf("Room database %s did not match current authority", operation)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,21 +22,17 @@ func databasePath(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "rooms.sqlite")
|
||||
}
|
||||
|
||||
func stableStore(t *testing.T, path string, testClock *clock, options Options) *Store {
|
||||
func stableStore(t *testing.T, path string, options Options) *Store {
|
||||
t.Helper()
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if options.LeaseMs == 0 {
|
||||
options.LeaseMs = 1_000
|
||||
}
|
||||
if options.MaxRooms == 0 {
|
||||
options.MaxRooms = 8
|
||||
}
|
||||
options.MaxViewersPerRoom = 8
|
||||
options.Database = database
|
||||
options.Now = func() int64 { return testClock.nowMs }
|
||||
store, err := New(options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
@@ -72,8 +69,7 @@ func dbViewerInput(roomID, sessionID, viewerGrant string) ConnectParticipantInpu
|
||||
|
||||
func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 1_000}
|
||||
first := stableStore(t, path, testClock, Options{MaxRooms: 1})
|
||||
first := stableStore(t, path, Options{MaxRooms: 1})
|
||||
|
||||
withSaturatedGate(t, first, func() {
|
||||
_, err := tryCreateRoom(first, protocol.CodeEntryPrivate, "room-password", "4321")
|
||||
@@ -86,7 +82,7 @@ func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{MaxRooms: 1})
|
||||
second := stableStore(t, path, Options{MaxRooms: 1})
|
||||
if second.Size() != 0 {
|
||||
t.Fatalf("restored size = %d, want 0", second.Size())
|
||||
}
|
||||
@@ -97,8 +93,7 @@ func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) {
|
||||
|
||||
func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 1_000}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
first := stableStore(t, path, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
if _, err := first.SetCodeEntryPolicy(
|
||||
room.RoomID, protocol.CodeEntryPrivate, room.HostToken); err != nil {
|
||||
@@ -124,8 +119,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 2_000
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
second := stableStore(t, path, Options{})
|
||||
if second.Size() != 1 {
|
||||
t.Fatalf("restored size = %d, want 1", second.Size())
|
||||
}
|
||||
@@ -139,9 +133,6 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
|
||||
granted := mustConnect(t, second, dbViewerInput(room.RoomID, "current-grant", rotated.ViewerGrant))
|
||||
if granted.ExpiresAt == nil || *granted.ExpiresAt != "1970-01-01T00:00:03.000Z" {
|
||||
t.Fatalf("expiresAt = %v", granted.ExpiresAt)
|
||||
}
|
||||
if granted.ViewerAuthorizationGeneration != rotated.ViewerAuthorizationGeneration {
|
||||
t.Fatalf("generation = %q, want %q",
|
||||
granted.ViewerAuthorizationGeneration, rotated.ViewerAuthorizationGeneration)
|
||||
@@ -157,10 +148,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
}
|
||||
_, err = second.ConnectParticipant(hostInput(room.RoomID, "wrong-token"))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
resumed := mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host"))
|
||||
if resumed.ExpiresAt != nil {
|
||||
t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt)
|
||||
}
|
||||
mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host"))
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
@@ -178,7 +166,7 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
want := []string{
|
||||
"room_id", "host_token_digest", "viewer_grant_digest",
|
||||
"viewer_authorization_generation", "code_entry_policy",
|
||||
"viewer_password_material", "lease_expires_at_ms",
|
||||
"viewer_password_material",
|
||||
}
|
||||
if strings.Join(columns, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("columns = %v", columns)
|
||||
@@ -187,16 +175,15 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
t.Fatal("no stored room")
|
||||
}
|
||||
var (
|
||||
roomID string
|
||||
hostDigest []byte
|
||||
grantDigest []byte
|
||||
generation string
|
||||
policy string
|
||||
material []byte
|
||||
leaseExpiresAtMs sql.NullInt64
|
||||
roomID string
|
||||
hostDigest []byte
|
||||
grantDigest []byte
|
||||
generation string
|
||||
policy string
|
||||
material []byte
|
||||
)
|
||||
if err := rows.Scan(&roomID, &hostDigest, &grantDigest, &generation, &policy,
|
||||
&material, &leaseExpiresAtMs); err != nil {
|
||||
&material); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
hostTokenDigest := sha256.Sum256([]byte(room.HostToken))
|
||||
@@ -219,58 +206,11 @@ func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertsAnActiveMarkerOnceAndPreservesDormantDeadlines(t *testing.T) {
|
||||
func TestPreservesDisconnectedAuthorityAcrossRepeatedRestarts(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
|
||||
mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 500
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
firstRestart := mustConnect(t, second, dbViewerInput(room.RoomID, "first-restart", room.ViewerGrant))
|
||||
if firstRestart.ExpiresAt == nil || *firstRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
|
||||
t.Fatalf("expiresAt = %v", firstRestart.ExpiresAt)
|
||||
}
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 700
|
||||
third := stableStore(t, path, testClock, Options{})
|
||||
secondRestart := mustConnect(t, third, dbViewerInput(room.RoomID, "second-restart", room.ViewerGrant))
|
||||
if secondRestart.ExpiresAt == nil || *secondRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" {
|
||||
t.Fatalf("expiresAt = %v", secondRestart.ExpiresAt)
|
||||
}
|
||||
if err := third.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 1_501
|
||||
fourth := stableStore(t, path, testClock, Options{})
|
||||
if fourth.Size() != 0 {
|
||||
t.Fatalf("size = %d, want 0", fourth.Size())
|
||||
}
|
||||
replacement := createRoom(t, fourth, protocol.CodeEntryOpen, "", room.RoomID)
|
||||
if replacement.RoomID != room.RoomID {
|
||||
t.Fatalf("replacement room = %q", replacement.RoomID)
|
||||
}
|
||||
_, err := fourth.ConnectParticipant(hostInput(room.RoomID, room.HostToken))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
_, err = fourth.ConnectParticipant(dbViewerInput(room.RoomID, "stale-grant", room.ViewerGrant))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
}
|
||||
|
||||
func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
first := stableStore(t, path, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "5890")
|
||||
host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken))
|
||||
testClock.nowMs = 250
|
||||
if _, err := first.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil {
|
||||
t.Fatalf("DisconnectParticipant: %v", err)
|
||||
}
|
||||
@@ -278,18 +218,25 @@ func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
testClock.nowMs = 500
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
dormant := mustConnect(t, second, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant))
|
||||
if dormant.ExpiresAt == nil || *dormant.ExpiresAt != "1970-01-01T00:00:01.250Z" {
|
||||
t.Fatalf("expiresAt = %v", dormant.ExpiresAt)
|
||||
for range 3 {
|
||||
restored := stableStore(t, path, Options{})
|
||||
if restored.Size() != 1 {
|
||||
t.Fatalf("size = %d, want 1", restored.Size())
|
||||
}
|
||||
viewer := mustConnect(t, restored, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant))
|
||||
if viewer.HostOnline {
|
||||
t.Fatal("restored room reported an online host")
|
||||
}
|
||||
mustConnect(t, restored, hostInput(room.RoomID, room.HostToken))
|
||||
if err := restored.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
first := stableStore(t, path, Options{})
|
||||
room := createRoom(t, first, protocol.CodeEntryOpen, "", "6789")
|
||||
closed, err := first.AbandonRoom(room.RoomID)
|
||||
if err != nil || closed == nil || closed.RoomID != room.RoomID {
|
||||
@@ -299,7 +246,7 @@ func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
second := stableStore(t, path, Options{})
|
||||
if second.Size() != 0 {
|
||||
t.Fatalf("size = %d, want 0", second.Size())
|
||||
}
|
||||
@@ -312,48 +259,9 @@ func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollsBackABatchExpiryWhenAnyAuthorityIsStale(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "5678")
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
restored, err := database.Initialize(testClock.nowMs, 1_010)
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if len(restored) != 2 {
|
||||
t.Fatalf("restored %d rooms, want 2", len(restored))
|
||||
}
|
||||
err = database.DeleteRooms([]RoomIdentity{
|
||||
{RoomID: restored[0].RoomID, HostTokenDigest: restored[0].HostTokenDigest},
|
||||
{RoomID: restored[1].RoomID, HostTokenDigest: make([]byte, 32)},
|
||||
})
|
||||
if err == nil || err.Error() != "Room database delete did not match current authority" {
|
||||
t.Fatalf("DeleteRooms: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
if second.Size() != 2 {
|
||||
t.Fatalf("size = %d, want 2", second.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 10}
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
first := stableStore(t, path, Options{})
|
||||
original := createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
replacement, err := tryReplaceRoom(
|
||||
first, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "new-password")
|
||||
@@ -364,7 +272,7 @@ func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
second := stableStore(t, path, testClock, Options{})
|
||||
second := stableStore(t, path, Options{})
|
||||
if second.Size() != 1 {
|
||||
t.Fatalf("size = %d, want 1", second.Size())
|
||||
}
|
||||
@@ -386,14 +294,44 @@ func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollsBackReplacementWhenOldAuthorityIsStale(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
first := stableStore(t, path, Options{})
|
||||
createRoom(t, first, protocol.CodeEntryPrivate, "room-password", "4321")
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
before, err := database.Initialize()
|
||||
if err != nil || len(before) != 1 {
|
||||
t.Fatalf("Initialize = %v, %v", before, err)
|
||||
}
|
||||
replacement := before[0]
|
||||
replacement.RoomID = "5678"
|
||||
err = database.ReplaceRoom("4321", make([]byte, 32), replacement)
|
||||
if err == nil || err.Error() != "Room database replacement delete did not match current authority" {
|
||||
t.Fatalf("ReplaceRoom = %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := database.Initialize()
|
||||
if err != nil || !reflect.DeepEqual(after, before) {
|
||||
t.Fatalf("failed replacement changed durable authority: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpensThePathWithURIReservedCharactersLiterally: `new DatabaseSync(path)`
|
||||
// opened the literal path, while the driver's DSN is a URI in which `%41`
|
||||
// decodes to `A` and `#` ends the file name; the file must still be the
|
||||
// configured one, and the busy-timeout pragma must survive the escaping.
|
||||
func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) {
|
||||
testClock := &clock{nowMs: 10}
|
||||
path := filepath.Join(t.TempDir(), "rooms%41#1.sqlite")
|
||||
first := stableStore(t, path, testClock, Options{})
|
||||
first := stableStore(t, path, Options{})
|
||||
createRoom(t, first, protocol.CodeEntryOpen, "", "4321")
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
@@ -401,7 +339,7 @@ func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("the configured file was not the one opened: %v", err)
|
||||
}
|
||||
if second := stableStore(t, path, testClock, Options{}); second.Size() != 1 {
|
||||
if second := stableStore(t, path, Options{}); second.Size() != 1 {
|
||||
t.Fatalf("restored size = %d, want 1", second.Size())
|
||||
}
|
||||
}
|
||||
@@ -412,14 +350,14 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := first.Initialize(0, 1_000); err != nil {
|
||||
if _, err := first.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
contender, err := NewDatabase(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = contender.Initialize(0, 1_000)
|
||||
_, err = contender.Initialize()
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") {
|
||||
t.Fatalf("contender Initialize = %v, want a lock failure", err)
|
||||
}
|
||||
@@ -434,7 +372,7 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
restored, err := successor.Initialize(0, 1_000)
|
||||
restored, err := successor.Initialize()
|
||||
if err != nil || len(restored) != 0 {
|
||||
t.Fatalf("successor Initialize = %v, %v", restored, err)
|
||||
}
|
||||
@@ -446,7 +384,7 @@ func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) {
|
||||
func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
|
||||
for _, testCase := range []struct{ name, mutation string }{
|
||||
{"application identity", "PRAGMA application_id = 1"},
|
||||
{"schema version", "PRAGMA user_version = 2"},
|
||||
{"schema version", "PRAGMA user_version = 99"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
@@ -454,7 +392,7 @@ func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := initial.Initialize(0, 1_000); err != nil {
|
||||
if _, err := initial.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := initial.Close(); err != nil {
|
||||
@@ -466,7 +404,7 @@ func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = reopened.Initialize(0, 1_000)
|
||||
_, err = reopened.Initialize()
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
@@ -483,7 +421,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := initial.Initialize(0, 1_000); err != nil {
|
||||
if _, err := initial.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if err := initial.Close(); err != nil {
|
||||
@@ -495,7 +433,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
_, err = reopened.Initialize(0, 1_000)
|
||||
_, err = reopened.Initialize()
|
||||
if err == nil || err.Error() != "Room database columns do not match the current schema" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
@@ -506,8 +444,7 @@ func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) {
|
||||
|
||||
func TestRejectsCorruptAuthorityRows(t *testing.T) {
|
||||
path := databasePath(t)
|
||||
testClock := &clock{nowMs: 100}
|
||||
initial := stableStore(t, path, testClock, Options{})
|
||||
initial := stableStore(t, path, Options{})
|
||||
createRoom(t, initial, protocol.CodeEntryOpen, "", "7890")
|
||||
if err := initial.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
@@ -529,10 +466,8 @@ func TestRejectsCorruptAuthorityRows(t *testing.T) {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
reopened, err := New(Options{
|
||||
LeaseMs: 1_000,
|
||||
MaxRooms: 8,
|
||||
MaxViewersPerRoom: 8,
|
||||
Now: func() int64 { return testClock.nowMs },
|
||||
Database: database,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -550,9 +485,7 @@ func TestRejectsCorruptAuthorityRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectsInvalidAuthorityArguments keeps the assertions the TypeScript ran
|
||||
// before touching SQLite: STRICT would accept an integral REAL or a numeric
|
||||
// TEXT timestamp, and a caller mistake must not reach the file at all.
|
||||
// Invalid authority must be rejected before mutating SQLite.
|
||||
func TestRejectsInvalidAuthorityArguments(t *testing.T) {
|
||||
valid := StoredRoomAuthority{
|
||||
RoomID: "1234",
|
||||
@@ -561,7 +494,6 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
|
||||
ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw",
|
||||
CodeEntryPolicy: protocol.CodeEntryOpen,
|
||||
}
|
||||
lease := int64(protocol.MaxSafeInteger + 1)
|
||||
shortDigest := make([]byte, 31)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
@@ -598,13 +530,8 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
|
||||
room.ViewerPasswordMaterial = make([]byte, 47)
|
||||
return d.InsertRoom(room)
|
||||
}, "Viewer password material must contain 48 bytes"},
|
||||
{"lease deadline", func(d *Database) error {
|
||||
room := valid
|
||||
room.LeaseExpiresAtMs = &lease
|
||||
return d.InsertRoom(room)
|
||||
}, "Room database lease deadline must be a safe positive integer"},
|
||||
{"identity", func(d *Database) error {
|
||||
return d.SetLeaseDeadline("123", make([]byte, 32), nil)
|
||||
return d.DeleteRoom("123", make([]byte, 32))
|
||||
}, "Room ID is invalid"},
|
||||
{"update policy", func(d *Database) error {
|
||||
return d.SetCodeEntryPolicy("1234", make([]byte, 32), "public")
|
||||
@@ -618,7 +545,7 @@ func TestRejectsInvalidAuthorityArguments(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
if _, err := database.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
@@ -644,10 +571,10 @@ func TestRejectsASecondInitialize(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err != nil {
|
||||
if _, err := database.Initialize(); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(0, 1_000); err == nil ||
|
||||
if _, err := database.Initialize(); err == nil ||
|
||||
err.Error() != "Room database is already initialized" {
|
||||
t.Fatalf("second Initialize = %v", err)
|
||||
}
|
||||
@@ -664,21 +591,3 @@ func TestRejectsASecondInitialize(t *testing.T) {
|
||||
t.Fatalf("InsertRoom after Close = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAnActiveLeaseThatIsNotInTheFuture(t *testing.T) {
|
||||
database, err := NewDatabase(databasePath(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(1_000, 1_000); err == nil ||
|
||||
err.Error() != "Room database active-room lease deadline must be in the future" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if _, err := database.Initialize(-1, 1_000); err == nil ||
|
||||
err.Error() != "Room database startup time must be a safe positive integer" {
|
||||
t.Fatalf("Initialize = %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package room ports the room authority: src/server/room-store.ts (rooms,
|
||||
// participants, room codes, viewer credentials, the four-digit code pool) and
|
||||
// src/server/room-database.ts (the SQLite stable authority). Every mutation
|
||||
// writes SQLite before memory and returns the write error unchanged; there is
|
||||
// no catch-and-continue anywhere, exactly as in the TypeScript.
|
||||
// src/server/room-database.ts (the SQLite stable authority). Authority mutations
|
||||
// write SQLite before memory and return the write error unchanged. Participants
|
||||
// remain process-only.
|
||||
//
|
||||
// # Locking
|
||||
//
|
||||
@@ -21,8 +21,7 @@
|
||||
// POST /api/rooms (createRoom):
|
||||
//
|
||||
// mu.Lock()
|
||||
// lease, err := store.BeginCreateRoom() // ROOM_LIMIT + lease deadline, pre-KDF
|
||||
// if err != nil { mu.Unlock(); return err }
|
||||
// if err := store.BeginCreateRoom(); err != nil { mu.Unlock(); return err }
|
||||
// var material []byte
|
||||
// var derived error
|
||||
// if password != "" {
|
||||
@@ -30,7 +29,7 @@
|
||||
// material, derived = store.DeriveViewerPasswordMaterial(password, nil)
|
||||
// mu.Lock()
|
||||
// }
|
||||
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID, lease)
|
||||
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID)
|
||||
// mu.Unlock()
|
||||
//
|
||||
// replaceRoom and set-viewer-password (the mayStart closure is the TypeScript
|
||||
@@ -38,7 +37,7 @@
|
||||
// the derive goroutine, so it takes the lock itself):
|
||||
//
|
||||
// mu.Lock()
|
||||
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN/ROOM_EXPIRED
|
||||
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN
|
||||
// if err != nil { mu.Unlock(); return err }
|
||||
// var material []byte
|
||||
// var derived error
|
||||
|
||||
+15
-126
@@ -12,7 +12,6 @@ import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/ordered"
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
@@ -32,7 +31,6 @@ const (
|
||||
CodeRoomAccessDenied ErrorCode = "ROOM_ACCESS_DENIED"
|
||||
CodeRoomBusy ErrorCode = "ROOM_BUSY"
|
||||
CodeRoomNotFound ErrorCode = "ROOM_NOT_FOUND"
|
||||
CodeRoomExpired ErrorCode = "ROOM_EXPIRED"
|
||||
CodeRoomFull ErrorCode = "ROOM_FULL"
|
||||
CodeHostAlreadyConnected ErrorCode = "HOST_ALREADY_CONNECTED"
|
||||
CodeRoomLimit ErrorCode = "ROOM_LIMIT"
|
||||
@@ -78,8 +76,6 @@ type Room struct {
|
||||
viewerPasswordMaterial []byte
|
||||
viewerAuthorizationGeneration string
|
||||
codeEntryPolicy protocol.CodeEntryPolicy
|
||||
sharingActive bool
|
||||
leaseExpiresAtMs *int64
|
||||
host *participant
|
||||
// ordering: JS Map insertion order decides viewer presence order, the
|
||||
// closed-session list and the router's participant order.
|
||||
@@ -92,7 +88,6 @@ type CreatedRoom struct {
|
||||
HostToken string
|
||||
CodeEntryPolicy protocol.CodeEntryPolicy
|
||||
ViewerGrant string
|
||||
ExpiresAt *string
|
||||
}
|
||||
|
||||
// ConnectParticipantInput is ConnectParticipantInput; Token is read for a host
|
||||
@@ -121,7 +116,6 @@ type ConnectedParticipant struct {
|
||||
RoomID string
|
||||
Role protocol.Role
|
||||
PeerID string
|
||||
ExpiresAt *string
|
||||
HostOnline bool
|
||||
ReplacedSessionID string
|
||||
CodeEntryPolicy protocol.CodeEntryPolicy
|
||||
@@ -177,24 +171,20 @@ type CodeEntryUpdate struct {
|
||||
|
||||
// Options is RoomStoreOptions.
|
||||
type Options struct {
|
||||
LeaseMs int64
|
||||
MaxRooms int
|
||||
MaxViewersPerRoom int
|
||||
Database *Database
|
||||
Now func() int64
|
||||
Random func(size int) []byte
|
||||
}
|
||||
|
||||
// Store is RoomStore. See the package comment for the locking contract.
|
||||
type Store struct {
|
||||
// ordering: JS Map insertion order decides AbandonAllRooms/ExpireRooms.
|
||||
// ordering: JS Map insertion order decides AbandonAllRooms.
|
||||
rooms ordered.Map[string, *Room]
|
||||
freeRoomCodes []string
|
||||
now func() int64
|
||||
random func(size int) []byte
|
||||
database *Database
|
||||
initialized bool
|
||||
leaseMs int64
|
||||
maxRooms int
|
||||
maxViewersPerRoom int
|
||||
// gate is per store; the TypeScript module-level gate was shared by every
|
||||
@@ -205,9 +195,6 @@ type Store struct {
|
||||
// New is the RoomStore constructor; the TypeScript threw where this returns an
|
||||
// error.
|
||||
func New(options Options) (*Store, error) {
|
||||
if options.LeaseMs <= 0 || options.LeaseMs > protocol.MaxSafeInteger {
|
||||
return nil, errors.New("Room lease must be a positive integer")
|
||||
}
|
||||
if options.MaxRooms <= 0 || options.MaxRooms > Capacity {
|
||||
return nil, fmt.Errorf("Room limit must be an integer between 1 and %d", Capacity)
|
||||
}
|
||||
@@ -219,11 +206,9 @@ func New(options Options) (*Store, error) {
|
||||
}
|
||||
store := &Store{
|
||||
freeRoomCodes: make([]string, Capacity),
|
||||
now: options.Now,
|
||||
random: options.Random,
|
||||
database: options.Database,
|
||||
initialized: options.Database == nil,
|
||||
leaseMs: options.LeaseMs,
|
||||
maxRooms: options.MaxRooms,
|
||||
maxViewersPerRoom: options.MaxViewersPerRoom,
|
||||
gate: gate{limit: gateActiveLimit, pendingLimit: gatePendingLimit},
|
||||
@@ -231,9 +216,6 @@ func New(options Options) (*Store, error) {
|
||||
for index := range store.freeRoomCodes {
|
||||
store.freeRoomCodes[index] = strconv.Itoa(roomCodeFirst + index)
|
||||
}
|
||||
if store.now == nil {
|
||||
store.now = func() int64 { return time.Now().UnixMilli() }
|
||||
}
|
||||
if store.random == nil {
|
||||
store.random = cryptoRandom
|
||||
}
|
||||
@@ -270,7 +252,7 @@ func (s *Store) Initialize() error {
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
if err := s.restore(s.now()); err != nil {
|
||||
if err := s.restore(); err != nil {
|
||||
s.rooms.Clear()
|
||||
_ = s.database.Close()
|
||||
return err
|
||||
@@ -279,12 +261,8 @@ func (s *Store) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) restore(startupNowMs int64) error {
|
||||
lease, err := s.leaseDeadline(startupNowMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storedRooms, err := s.database.Initialize(startupNowMs, lease)
|
||||
func (s *Store) restore() error {
|
||||
storedRooms, err := s.database.Initialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -306,8 +284,6 @@ func (s *Store) restore(startupNowMs int64) error {
|
||||
viewerPasswordMaterial: bytes.Clone(stored.ViewerPasswordMaterial),
|
||||
viewerAuthorizationGeneration: stored.ViewerAuthorizationGeneration,
|
||||
codeEntryPolicy: stored.CodeEntryPolicy,
|
||||
sharingActive: false,
|
||||
leaseExpiresAtMs: stored.LeaseExpiresAtMs,
|
||||
})
|
||||
}
|
||||
available := s.freeRoomCodes[:0]
|
||||
@@ -320,28 +296,25 @@ func (s *Store) restore(startupNowMs int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginCreateRoom runs the part of createRoom that precedes the password
|
||||
// derivation: the room limit and the lease deadline, which the TypeScript took
|
||||
// from the clock before awaiting the KDF. Pass the deadline to CreateRoom.
|
||||
func (s *Store) BeginCreateRoom() (leaseExpiresAtMs int64, err error) {
|
||||
// BeginCreateRoom checks room capacity before password derivation.
|
||||
func (s *Store) BeginCreateRoom() error {
|
||||
if err := s.ensureInitialized(); err != nil {
|
||||
return 0, err
|
||||
return err
|
||||
}
|
||||
if s.rooms.Len() >= s.maxRooms {
|
||||
return 0, codeError(CodeRoomLimit)
|
||||
return codeError(CodeRoomLimit)
|
||||
}
|
||||
return s.leaseDeadline(s.now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateRoom is the commit half of createRoom. material and derived are the
|
||||
// results of DeriveViewerPasswordMaterial (both zero when no password was
|
||||
// given) and leaseExpiresAtMs comes from BeginCreateRoom.
|
||||
// given).
|
||||
func (s *Store) CreateRoom(
|
||||
policy protocol.CodeEntryPolicy,
|
||||
material []byte,
|
||||
derived error,
|
||||
preferredRoomID string,
|
||||
leaseExpiresAtMs int64,
|
||||
) (CreatedRoom, error) {
|
||||
// createViewerPasswordMaterial threw before any of the checks below.
|
||||
if derived != nil && !isGateOutcome(derived) {
|
||||
@@ -363,7 +336,7 @@ func (s *Store) CreateRoom(
|
||||
if err != nil {
|
||||
return CreatedRoom{}, err
|
||||
}
|
||||
room, created, err := s.newRoom(roomID, policy, material, leaseExpiresAtMs)
|
||||
room, created, err := s.newRoom(roomID, policy, material)
|
||||
if err == nil {
|
||||
err = s.writeDatabase(func() error { return s.database.InsertRoom(storedRoomAuthority(room)) })
|
||||
}
|
||||
@@ -437,11 +410,7 @@ func (s *Store) replaceRoom(
|
||||
material []byte,
|
||||
current *Room,
|
||||
) (ReplacedRoom, error) {
|
||||
leaseExpiresAtMs, err := s.leaseDeadline(s.now())
|
||||
if err != nil {
|
||||
return ReplacedRoom{}, err
|
||||
}
|
||||
room, created, err := s.newRoom(replacementRoomID, policy, material, leaseExpiresAtMs)
|
||||
room, created, err := s.newRoom(replacementRoomID, policy, material)
|
||||
if err != nil {
|
||||
return ReplacedRoom{}, err
|
||||
}
|
||||
@@ -591,7 +560,7 @@ func (s *Store) ConnectParticipant(input ConnectParticipantInput) (ConnectedPart
|
||||
room, err := s.getAvailableRoom(input.RoomID)
|
||||
if err != nil {
|
||||
if input.Role == protocol.RoleViewer && input.ViewerGrant == "" &&
|
||||
(hasCode(err, CodeInvalidToken) || hasCode(err, CodeRoomExpired)) {
|
||||
hasCode(err, CodeInvalidToken) {
|
||||
return ConnectedParticipant{}, codeError(CodeRoomNotFound)
|
||||
}
|
||||
return ConnectedParticipant{}, err
|
||||
@@ -633,8 +602,8 @@ func (s *Store) ViewerGrantMayEnter(roomID, grant string) (bool, error) {
|
||||
|
||||
// ViewerPasswordChallenge is the locked first half of connectViewerWithPassword:
|
||||
// it returns the salt to derive against, a copy of the material to compare with
|
||||
// and the room reference to re-check afterwards. A missing room, an expired
|
||||
// room and a room without a private password all answer with the same 48 zero
|
||||
// and the room reference to re-check afterwards. A missing room and a room
|
||||
// without a private password both answer with the same 48 zero
|
||||
// bytes so the derivation cost does not reveal which. The only error is the
|
||||
// `ensureInitialized()` the TypeScript ran before the password check.
|
||||
func (s *Store) ViewerPasswordChallenge(
|
||||
@@ -711,19 +680,7 @@ func (s *Store) DisconnectParticipant(
|
||||
if room.host.sessionID != sessionID {
|
||||
return nil, nil
|
||||
}
|
||||
leaseExpiresAtMs, err := s.leaseDeadline(s.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = s.writeDatabase(func() error {
|
||||
return s.database.SetLeaseDeadline(roomID, room.hostTokenDigest, &leaseExpiresAtMs)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
room.host.sessionID = ""
|
||||
room.sharingActive = false
|
||||
room.leaseExpiresAtMs = &leaseExpiresAtMs
|
||||
return &DisconnectedParticipant{
|
||||
RoomID: roomID, Role: protocol.RoleHost, PeerID: peerID}, nil
|
||||
}
|
||||
@@ -841,35 +798,6 @@ func (s *Store) AbandonAllRooms() ([]ClosedRoom, error) {
|
||||
return closedRooms, nil
|
||||
}
|
||||
|
||||
// ExpireRooms is expireRooms, in room insertion order. The TypeScript defaulted
|
||||
// nowMs to the injected clock; every caller passed it explicitly.
|
||||
func (s *Store) ExpireRooms(nowMs int64) ([]ClosedRoom, error) {
|
||||
if err := s.ensureInitialized(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var expiredIDs []string
|
||||
var identities []RoomIdentity
|
||||
var expired []ClosedRoom
|
||||
for roomID, room := range s.rooms.All() {
|
||||
if !roomIsExpired(room, nowMs) {
|
||||
continue
|
||||
}
|
||||
expiredIDs = append(expiredIDs, roomID)
|
||||
identities = append(identities, RoomIdentity{
|
||||
RoomID: roomID, HostTokenDigest: room.hostTokenDigest})
|
||||
expired = append(expired, ClosedRoom{
|
||||
RoomID: roomID, SessionIDs: connectedSessionIDs(room)})
|
||||
}
|
||||
if err := s.writeDatabase(func() error { return s.database.DeleteRooms(identities) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, roomID := range expiredIDs {
|
||||
s.rooms.Delete(roomID)
|
||||
s.releaseRoomCode(roomID)
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
|
||||
func (s *Store) connectHost(
|
||||
room *Room, input ConnectParticipantInput,
|
||||
) (ConnectedParticipant, error) {
|
||||
@@ -888,16 +816,8 @@ func (s *Store) connectHost(
|
||||
clientID: input.ClientID, peerID: peerID, admittedBy: admittedByCode}
|
||||
}
|
||||
replacedSessionID := joining.sessionID
|
||||
err := s.writeDatabase(func() error {
|
||||
return s.database.SetLeaseDeadline(room.roomID, room.hostTokenDigest, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return ConnectedParticipant{}, err
|
||||
}
|
||||
joining.sessionID = input.SessionID
|
||||
room.host = joining
|
||||
room.sharingActive = true
|
||||
room.leaseExpiresAtMs = nil
|
||||
|
||||
return connectedParticipant(
|
||||
room, joining.peerID, protocol.RoleHost, replacedSessionID, input.SessionID), nil
|
||||
@@ -936,7 +856,6 @@ func connectedParticipant(
|
||||
RoomID: room.roomID,
|
||||
Role: role,
|
||||
PeerID: peerID,
|
||||
ExpiresAt: formatExpiresAt(room.leaseExpiresAtMs),
|
||||
HostOnline: room.host != nil && room.host.sessionID != "",
|
||||
ReplacedSessionID: replacedSessionID,
|
||||
CodeEntryPolicy: room.codeEntryPolicy,
|
||||
@@ -950,9 +869,6 @@ func (s *Store) getAvailableRoom(roomID string) (*Room, error) {
|
||||
if !ok {
|
||||
return nil, codeError(CodeInvalidToken)
|
||||
}
|
||||
if roomIsExpired(room, s.now()) {
|
||||
return nil, codeError(CodeRoomExpired)
|
||||
}
|
||||
return room, nil
|
||||
}
|
||||
|
||||
@@ -1067,7 +983,6 @@ func (s *Store) newRoom(
|
||||
roomID string,
|
||||
policy protocol.CodeEntryPolicy,
|
||||
material []byte,
|
||||
leaseExpiresAtMs int64,
|
||||
) (*Room, CreatedRoom, error) {
|
||||
hostTokenBytes := s.random(32)
|
||||
if len(hostTokenBytes) != 32 {
|
||||
@@ -1089,15 +1004,12 @@ func (s *Store) newRoom(
|
||||
viewerPasswordMaterial: material,
|
||||
viewerAuthorizationGeneration: generation,
|
||||
codeEntryPolicy: policy,
|
||||
sharingActive: false,
|
||||
leaseExpiresAtMs: &leaseExpiresAtMs,
|
||||
}
|
||||
return room, CreatedRoom{
|
||||
RoomID: roomID,
|
||||
HostToken: hostToken,
|
||||
CodeEntryPolicy: policy,
|
||||
ViewerGrant: viewerGrant,
|
||||
ExpiresAt: formatExpiresAt(&leaseExpiresAtMs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1117,15 +1029,6 @@ func (s *Store) newAuthorizationGeneration() (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func (s *Store) leaseDeadline(nowMs int64) (int64, error) {
|
||||
deadline := nowMs + s.leaseMs
|
||||
if deadline <= nowMs || deadline > protocol.MaxSafeInteger ||
|
||||
deadline < -protocol.MaxSafeInteger {
|
||||
return 0, errors.New("Room lease deadline exceeds the supported time range")
|
||||
}
|
||||
return deadline, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureInitialized() error {
|
||||
if !s.initialized {
|
||||
return errors.New("RoomStore stable authority is not initialized")
|
||||
@@ -1150,23 +1053,9 @@ func storedRoomAuthority(room *Room) StoredRoomAuthority {
|
||||
ViewerAuthorizationGeneration: room.viewerAuthorizationGeneration,
|
||||
CodeEntryPolicy: room.codeEntryPolicy,
|
||||
ViewerPasswordMaterial: room.viewerPasswordMaterial,
|
||||
LeaseExpiresAtMs: room.leaseExpiresAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
func roomIsExpired(room *Room, nowMs int64) bool {
|
||||
return !room.sharingActive && room.leaseExpiresAtMs != nil &&
|
||||
*room.leaseExpiresAtMs <= nowMs
|
||||
}
|
||||
|
||||
func formatExpiresAt(expiresAtMs *int64) *string {
|
||||
if expiresAtMs == nil {
|
||||
return nil
|
||||
}
|
||||
value := time.UnixMilli(*expiresAtMs).UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
return &value
|
||||
}
|
||||
|
||||
func findViewerByPeerID(room *Room, peerID string) *participant {
|
||||
for _, viewer := range room.viewers.Values() {
|
||||
if viewer.peerID == peerID {
|
||||
|
||||
@@ -16,21 +16,14 @@ import (
|
||||
|
||||
// The scenarios below are ported from tests/room-store.test.ts.
|
||||
|
||||
type clock struct{ nowMs int64 }
|
||||
|
||||
func newStore(t *testing.T, options Options) (*Store, *clock) {
|
||||
func newStore(t *testing.T, options Options) *Store {
|
||||
t.Helper()
|
||||
testClock := &clock{}
|
||||
if options.LeaseMs == 0 {
|
||||
options.LeaseMs = 1_000
|
||||
}
|
||||
if options.MaxRooms == 0 {
|
||||
options.MaxRooms = 2
|
||||
}
|
||||
if options.MaxViewersPerRoom == 0 {
|
||||
options.MaxViewersPerRoom = 2
|
||||
}
|
||||
options.Now = func() int64 { return testClock.nowMs }
|
||||
store, err := New(options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
@@ -39,7 +32,7 @@ func newStore(t *testing.T, options Options) (*Store, *clock) {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store, testClock
|
||||
return store
|
||||
}
|
||||
|
||||
func constantRandom(value func(size int) byte) func(size int) []byte {
|
||||
@@ -51,8 +44,7 @@ func constantRandom(value func(size int) byte) func(size int) []byte {
|
||||
func tryCreateRoom(
|
||||
store *Store, policy protocol.CodeEntryPolicy, password, preferredRoomID string,
|
||||
) (CreatedRoom, error) {
|
||||
lease, err := store.BeginCreateRoom()
|
||||
if err != nil {
|
||||
if err := store.BeginCreateRoom(); err != nil {
|
||||
return CreatedRoom{}, err
|
||||
}
|
||||
var material []byte
|
||||
@@ -60,7 +52,7 @@ func tryCreateRoom(
|
||||
if password != "" {
|
||||
material, derived = store.DeriveViewerPasswordMaterial(password, nil)
|
||||
}
|
||||
return store.CreateRoom(policy, material, derived, preferredRoomID, lease)
|
||||
return store.CreateRoom(policy, material, derived, preferredRoomID)
|
||||
}
|
||||
|
||||
func createRoom(
|
||||
@@ -167,7 +159,7 @@ var roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{3}$`)
|
||||
var viewerGrantPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}[AQgw]$`)
|
||||
|
||||
func TestAllocatesEveryFreeRoomCodeAndRecyclesReleases(t *testing.T) {
|
||||
store, _ := newStore(t, Options{
|
||||
store := newStore(t, Options{
|
||||
MaxRooms: Capacity,
|
||||
Random: constantRandom(func(int) byte { return 0 }),
|
||||
})
|
||||
@@ -200,7 +192,7 @@ func TestAllocatesEveryFreeRoomCodeAndRecyclesReleases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUsesAFreePreferredCodeAndFallsBack(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 3})
|
||||
store := newStore(t, Options{MaxRooms: 3})
|
||||
|
||||
preferred := createRoom(t, store, protocol.CodeEntryOpen, "", "4321")
|
||||
fallback := createRoom(t, store, protocol.CodeEntryOpen, "", "4321")
|
||||
@@ -220,7 +212,7 @@ func TestUsesAFreePreferredCodeAndFallsBack(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAbandonsEveryCurrentRoomAsOneShutdown(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 3})
|
||||
store := newStore(t, Options{MaxRooms: 3})
|
||||
first := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
second := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
mustConnect(t, store, hostInput(first.RoomID, first.HostToken))
|
||||
@@ -248,7 +240,7 @@ func TestAbandonsEveryCurrentRoomAsOneShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAtomicallyReplacesARoomWithDifferentAuthority(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
original := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321")
|
||||
host := mustConnect(t, store, hostInput(original.RoomID, original.HostToken))
|
||||
mustConnect(t, store, viewerInput(original.RoomID, "viewer-session", original.ViewerGrant))
|
||||
@@ -293,56 +285,36 @@ func TestAtomicallyReplacesARoomWithDifferentAuthority(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRejectsRoomLimitsBeyondTheCodeSpace(t *testing.T) {
|
||||
_, err := New(Options{LeaseMs: 1_000, MaxRooms: Capacity + 1, MaxViewersPerRoom: 2})
|
||||
_, err := New(Options{MaxRooms: Capacity + 1, MaxViewersPerRoom: 2})
|
||||
if err == nil || err.Error() != "Room limit must be an integer between 1 and 9000" {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepsActiveSharingAliveAndRenewsOnlyWithTheExactHostToken(t *testing.T) {
|
||||
store, testClock := newStore(t, Options{LeaseMs: 1_000})
|
||||
func TestResumesDisconnectedRoomOnlyWithTheExactHostToken(t *testing.T) {
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
|
||||
|
||||
testClock.nowMs = 2_000
|
||||
if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 {
|
||||
t.Fatalf("ExpireRooms while sharing = %v, %v", expired, err)
|
||||
}
|
||||
|
||||
if _, err := store.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil {
|
||||
t.Fatalf("DisconnectParticipant: %v", err)
|
||||
}
|
||||
testClock.nowMs = 2_500
|
||||
if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 {
|
||||
t.Fatalf("ExpireRooms inside the lease = %v, %v", expired, err)
|
||||
}
|
||||
|
||||
_, err := store.ConnectParticipant(hostInput(room.RoomID, "wrong-token"))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
resumed := mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "host-session-2"))
|
||||
if resumed.ExpiresAt != nil {
|
||||
t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt)
|
||||
}
|
||||
|
||||
if _, err := store.DisconnectParticipant(
|
||||
room.RoomID, resumed.PeerID, "host-session-2"); err != nil {
|
||||
t.Fatalf("DisconnectParticipant: %v", err)
|
||||
}
|
||||
mustConnect(t, store, viewerInput(room.RoomID, "viewer-session", room.ViewerGrant))
|
||||
testClock.nowMs = 3_501
|
||||
expired, err := store.ExpireRooms(testClock.nowMs)
|
||||
if err != nil || len(expired) != 1 {
|
||||
t.Fatalf("ExpireRooms after the lease = %v, %v", expired, err)
|
||||
}
|
||||
_, err = store.ConnectParticipant(hostInput(room.RoomID, room.HostToken, "late-host"))
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "late-host"))
|
||||
}
|
||||
|
||||
func TestManagesDormantAccessWithTheExactHostTokenWithoutRenewing(t *testing.T) {
|
||||
store, testClock := newStore(t, Options{LeaseMs: 1_000})
|
||||
func TestManagesDormantAccessWithTheExactHostToken(t *testing.T) {
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
otherRoom := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
testClock.nowMs = 900
|
||||
|
||||
_, err := trySetViewerPassword(store, room.RoomID, "room-password", "wrong-token")
|
||||
expectCode(t, err, CodeInvalidToken)
|
||||
@@ -370,28 +342,10 @@ func TestManagesDormantAccessWithTheExactHostTokenWithoutRenewing(t *testing.T)
|
||||
if _, ok := store.GetConnectedHost(room.RoomID); ok {
|
||||
t.Fatal("a dormant room reported a connected host")
|
||||
}
|
||||
|
||||
testClock.nowMs = 1_001
|
||||
expired, err := store.ExpireRooms(testClock.nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireRooms: %v", err)
|
||||
}
|
||||
if !slicesContainsRoom(expired, room.RoomID) {
|
||||
t.Fatalf("expired = %+v, want %q", expired, room.RoomID)
|
||||
}
|
||||
}
|
||||
|
||||
func slicesContainsRoom(rooms []ClosedRoom, roomID string) bool {
|
||||
for _, room := range rooms {
|
||||
if room.RoomID == roomID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestKeepsViewerGrantsIndependentFromCodeEntryPolicy(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 3})
|
||||
store := newStore(t, Options{MaxRooms: 3})
|
||||
room := createRoom(t, store, protocol.CodeEntryPrivate, "", "")
|
||||
|
||||
_, err := store.ConnectParticipant(viewerInput(room.RoomID, "code-only", ""))
|
||||
@@ -410,14 +364,13 @@ func TestKeepsViewerGrantsIndependentFromCodeEntryPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKeepsTheExactViewerGrantValidForTheRoomIncarnation(t *testing.T) {
|
||||
store, testClock := newStore(t, Options{LeaseMs: 1_000})
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
|
||||
|
||||
if !viewerGrantPattern.MatchString(room.ViewerGrant) {
|
||||
t.Fatalf("viewer grant = %q", room.ViewerGrant)
|
||||
}
|
||||
testClock.nowMs = 8 * 24 * 60 * 60 * 1_000
|
||||
connected := mustConnect(t, store, viewerInput(room.RoomID, "room-lived-grant", room.ViewerGrant))
|
||||
if connected.Role != protocol.RoleViewer {
|
||||
t.Fatalf("role = %q", connected.Role)
|
||||
@@ -425,7 +378,7 @@ func TestKeepsTheExactViewerGrantValidForTheRoomIncarnation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSupportsOpenAndPasswordEnabledPrivateCodeEntry(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 3})
|
||||
store := newStore(t, Options{MaxRooms: 3})
|
||||
open := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
if connected := mustConnect(t, store, viewerInput(open.RoomID, "open", "")); connected.Role !=
|
||||
protocol.RoleViewer {
|
||||
@@ -495,7 +448,7 @@ func waitForSaturatedGate(t *testing.T, store *Store) {
|
||||
}
|
||||
|
||||
func TestDoesNotCreateAPasswordlessRoomWhenTheGateIsBusy(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 1})
|
||||
store := newStore(t, Options{MaxRooms: 1})
|
||||
|
||||
withSaturatedGate(t, store, func() {
|
||||
_, err := tryCreateRoom(store, protocol.CodeEntryPrivate, "room-password", "4321")
|
||||
@@ -510,7 +463,7 @@ func TestDoesNotCreateAPasswordlessRoomWhenTheGateIsBusy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKeepsGateSaturationDistinctFromInvalidCredentials(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "")
|
||||
|
||||
withSaturatedGate(t, store, func() {
|
||||
@@ -525,7 +478,7 @@ func TestKeepsGateSaturationDistinctFromInvalidCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKeepsRoomReplacementAndPasswordUpdatesUnchangedWhenBusy(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxRooms: 3})
|
||||
store := newStore(t, Options{MaxRooms: 3})
|
||||
room := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321")
|
||||
|
||||
withSaturatedGate(t, store, func() {
|
||||
@@ -560,7 +513,7 @@ func TestKeepsRoomReplacementAndPasswordUpdatesUnchangedWhenBusy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBoundsPasswordDerivationsAndUsesTheSamePathForUnknownRooms(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
|
||||
var mayStartCalls atomic.Int64
|
||||
release := make(chan struct{})
|
||||
@@ -611,7 +564,7 @@ func TestBoundsPasswordDerivationsAndUsesTheSamePathForUnknownRooms(t *testing.T
|
||||
}
|
||||
|
||||
func TestRotatesAndRevokesViewerGrantsWithoutChangingCodeEntry(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
|
||||
|
||||
@@ -646,7 +599,7 @@ func TestKeepsTheOldGrantWhenRotationCannotCreateAGeneration(t *testing.T) {
|
||||
failedRandomCall := 0
|
||||
randomCalls := 0
|
||||
randomValue := byte(0)
|
||||
store, _ := newStore(t, Options{
|
||||
store := newStore(t, Options{
|
||||
Random: func(size int) []byte {
|
||||
randomCalls++
|
||||
randomValue++
|
||||
@@ -680,9 +633,9 @@ func TestKeepsTheOldGrantWhenRotationCannotCreateAGeneration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClearsEveryCredentialOnProcessRestart(t *testing.T) {
|
||||
firstStore, _ := newStore(t, Options{})
|
||||
firstStore := newStore(t, Options{})
|
||||
room := createRoom(t, firstStore, protocol.CodeEntryPrivate, "room-password", "")
|
||||
secondStore, _ := newStore(t, Options{})
|
||||
secondStore := newStore(t, Options{})
|
||||
|
||||
replacement := createRoom(t, secondStore, protocol.CodeEntryOpen, "", "")
|
||||
if !roomCodePattern.MatchString(replacement.RoomID) {
|
||||
@@ -702,7 +655,7 @@ func TestClearsEveryCredentialOnProcessRestart(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
|
||||
firstStore, _ := newStore(t, Options{
|
||||
firstStore := newStore(t, Options{
|
||||
Random: constantRandom(func(size int) byte {
|
||||
if size == 8 {
|
||||
return 0
|
||||
@@ -715,7 +668,7 @@ func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
|
||||
t.Fatalf("AbandonRoom: %v", err)
|
||||
}
|
||||
|
||||
secondStore, _ := newStore(t, Options{
|
||||
secondStore := newStore(t, Options{
|
||||
Random: constantRandom(func(size int) byte {
|
||||
if size == 8 {
|
||||
return 0
|
||||
@@ -737,7 +690,7 @@ func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParticipantReplacementRules(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
room := createRoom(t, store, protocol.CodeEntryOpen, "", "")
|
||||
host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken))
|
||||
|
||||
@@ -823,7 +776,7 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
|
||||
// A constant random source gives a recreated room the same Host token, so
|
||||
// the identity comparison is reachable instead of being shadowed by the
|
||||
// token check.
|
||||
store, testClock := newStore(t, Options{
|
||||
store := newStore(t, Options{
|
||||
MaxRooms: 3,
|
||||
Random: constantRandom(func(int) byte { return 7 }),
|
||||
})
|
||||
@@ -856,15 +809,6 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("HostManagedRoom: %v", err)
|
||||
}
|
||||
testClock.nowMs = 5_000
|
||||
_, err = store.SetViewerPassword(again.RoomID, again.HostToken, nil, nil, incarnation)
|
||||
expectCode(t, err, CodeRoomExpired)
|
||||
// An expired room outranks a saturated gate, as the TypeScript order did.
|
||||
_, err = store.SetViewerPassword(
|
||||
again.RoomID, again.HostToken, nil, codeError(CodeRoomBusy), incarnation)
|
||||
expectCode(t, err, CodeRoomExpired)
|
||||
|
||||
testClock.nowMs = 0
|
||||
_, err = store.SetViewerPassword(
|
||||
again.RoomID, again.HostToken, nil, errDerivationCancelled, incarnation)
|
||||
expectCode(t, err, CodeRoomAccessDenied)
|
||||
@@ -883,7 +827,7 @@ func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) {
|
||||
func TestPasswordLoginRechecksTheRoomAfterTheDerivation(t *testing.T) {
|
||||
// A constant random source repeats the salt, so a recreated room holds the
|
||||
// same material and only the room identity separates the two incarnations.
|
||||
store, _ := newStore(t, Options{
|
||||
store := newStore(t, Options{
|
||||
MaxRooms: 3,
|
||||
Random: constantRandom(func(int) byte { return 7 }),
|
||||
})
|
||||
@@ -938,7 +882,7 @@ func TestPasswordLoginRequiresAnInitializedStore(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewDatabase: %v", err)
|
||||
}
|
||||
store, err := New(Options{LeaseMs: 1_000, MaxRooms: 2, MaxViewersPerRoom: 2, Database: database})
|
||||
store, err := New(Options{MaxRooms: 2, MaxViewersPerRoom: 2, Database: database})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -951,7 +895,7 @@ func TestPasswordLoginRequiresAnInitializedStore(t *testing.T) {
|
||||
// TestPasswordLoginHidesAFullRoom keeps ROOM_FULL from leaking to a Viewer that
|
||||
// authenticated with the room password.
|
||||
func TestPasswordLoginHidesAFullRoom(t *testing.T) {
|
||||
store, _ := newStore(t, Options{MaxViewersPerRoom: 2})
|
||||
store := newStore(t, Options{MaxViewersPerRoom: 2})
|
||||
room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "")
|
||||
mustConnect(t, store, viewerInput(room.RoomID, "first", room.ViewerGrant))
|
||||
mustConnect(t, store, viewerInput(room.RoomID, "second", room.ViewerGrant))
|
||||
@@ -966,9 +910,8 @@ func TestPasswordLoginHidesAFullRoom(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
lease, err := store.BeginCreateRoom()
|
||||
if err != nil {
|
||||
store := newStore(t, Options{})
|
||||
if err := store.BeginCreateRoom(); err != nil {
|
||||
t.Fatalf("BeginCreateRoom: %v", err)
|
||||
}
|
||||
material, derived := store.DeriveViewerPasswordMaterial(
|
||||
@@ -976,7 +919,7 @@ func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
|
||||
if material != nil || !errors.Is(derived, errDerivationCancelled) {
|
||||
t.Fatalf("derive = %v, %v", material, derived)
|
||||
}
|
||||
_, err = store.CreateRoom(protocol.CodeEntryPrivate, material, derived, "", lease)
|
||||
_, err := store.CreateRoom(protocol.CodeEntryPrivate, material, derived, "")
|
||||
if err == nil || err.Error() != "Room creation password derivation was cancelled" {
|
||||
t.Fatalf("CreateRoom = %v", err)
|
||||
}
|
||||
@@ -986,7 +929,7 @@ func TestCreateRoomReportsACancelledDerivation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRejectsAMalformedViewerPassword(t *testing.T) {
|
||||
store, _ := newStore(t, Options{})
|
||||
store := newStore(t, Options{})
|
||||
for _, password := range []string{"", "with space", strings.Repeat("a", 65)} {
|
||||
material, derived := store.DeriveViewerPasswordMaterial(password, nil)
|
||||
if material != nil || !hasCode(derived, CodeInvalidToken) {
|
||||
|
||||
BIN
Binary file not shown.
@@ -306,8 +306,8 @@ type ReconcileResult struct {
|
||||
Released []*Resource
|
||||
}
|
||||
|
||||
// SettleResult is the outcome of a candidate settlement call. Committed is
|
||||
// true only when CandidateReady committed the attempt (TS: optional
|
||||
// SettleResult is the outcome of candidate settlement or active replacement.
|
||||
// Committed is true only when CandidateReady committed the attempt (TS: optional
|
||||
// committed, read as truthy by the router). TS: SettleResult.
|
||||
type SettleResult struct {
|
||||
Accepted bool
|
||||
|
||||
@@ -778,13 +778,13 @@ func (c *Controller) InvalidateHostPublication(guard PublicationGuard, nowMs *in
|
||||
return true
|
||||
}
|
||||
|
||||
// AdoptDirectConnection rewrites the connection ID of a usable, active
|
||||
// direct edge the operation does not own; true when adopted (no-op when
|
||||
// unchanged).
|
||||
func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) bool {
|
||||
// AdoptDirectConnection accepts a replacement for the exact active edge.
|
||||
// Recovery supersedes optional preparation for that child when the replacement
|
||||
// arrives. Required availability work, including capacity reduction, keeps priority.
|
||||
func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput, nowMs int64) SettleResult {
|
||||
result := SettleResult{ActiveRevision: c.revision}
|
||||
edge, _ := c.upstreamByViewer.Get(input.ChildPeerID)
|
||||
if (c.operation != nil && c.operation.childPeerID == input.ChildPeerID) ||
|
||||
c.revision != input.RouteRevision ||
|
||||
if c.revision != input.RouteRevision ||
|
||||
edge == nil ||
|
||||
edge.Kind != UpstreamPeer ||
|
||||
edge.Transport != TransportDirect ||
|
||||
@@ -794,10 +794,17 @@ func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) boo
|
||||
edge.ParentSessionID != input.ParentSessionID ||
|
||||
edge.ConnectionID != input.ConnectionID ||
|
||||
input.NewConnectionID == "" {
|
||||
return false
|
||||
return result
|
||||
}
|
||||
if input.NewConnectionID == edge.ConnectionID {
|
||||
return true
|
||||
result.Accepted = true
|
||||
return result
|
||||
}
|
||||
if op := c.operation; op != nil && op.childPeerID == input.ChildPeerID {
|
||||
if isAvailabilityOperation(op.reason) {
|
||||
return result
|
||||
}
|
||||
result.Released = c.abortOperation(&nowMs, RejectionAborted)
|
||||
}
|
||||
c.clearQualityForParticipant(input.ChildPeerID)
|
||||
edge.ConnectionID = input.NewConnectionID
|
||||
@@ -805,7 +812,9 @@ func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) boo
|
||||
child.availabilityExhausted = false
|
||||
}
|
||||
c.touchFacts()
|
||||
return true
|
||||
result.Accepted = true
|
||||
result.ActiveRevision = c.revision
|
||||
return result
|
||||
}
|
||||
|
||||
// RetireHostPublication retires the physically active publication and its
|
||||
|
||||
@@ -1127,7 +1127,7 @@ func TestClearsQualityShadowForRelaySubtreeWhenSourceChanges(t *testing.T) {
|
||||
eq(t, routes.AdoptDirectConnection(AdoptDirectConnectionInput{
|
||||
EdgeGuard: edgeGuard(A, "host_session", revision, "a_from_host"),
|
||||
NewConnectionID: "a_from_host_recovered",
|
||||
}), true)
|
||||
}, 1_002).Accepted, true)
|
||||
eq(t, withQuality(1_002), 0)
|
||||
|
||||
for _, step := range []struct {
|
||||
@@ -1142,6 +1142,35 @@ func TestClearsQualityShadowForRelaySubtreeWhenSourceChanges(t *testing.T) {
|
||||
eq(t, withQuality(3_101), 2)
|
||||
}
|
||||
|
||||
func TestActiveRebuildRetiresBootstrapReservationWithoutLosingWaitingDemand(t *testing.T) {
|
||||
routes := newController(2, Options{SfuEnabled: true})
|
||||
for _, child := range []string{B, C} {
|
||||
addViewer(routes, child, 0, nil)
|
||||
routes.hydrateEdge(child, peerEdge(HOST, child+"_from_host"))
|
||||
}
|
||||
addViewer(routes, A, 0, ms(0))
|
||||
bootstrap := must(t, routes.Reconcile(0).Operation)
|
||||
eq(t, bootstrap.Reason, DemandSfuBootstrap)
|
||||
retained := edgeOf(t, routes, bootstrap.ChildPeerID)
|
||||
attempt := beginOperation(t, routes, BeginInput{
|
||||
NowMs: 1, ConnectionID: "carrier_sfu", PublicationGeneration: "publication",
|
||||
PublicationConnectionID: "publication_connection",
|
||||
Reservation: sfuCreateOverlap("subscription", "publication", "overlap"),
|
||||
})
|
||||
replaced := routes.AdoptDirectConnection(AdoptDirectConnectionInput{
|
||||
EdgeGuard: edgeGuard(bootstrap.ChildPeerID, "host_session", routes.Revision(), retained.ConnectionID),
|
||||
NewConnectionID: "recovered_connection",
|
||||
}, 2)
|
||||
eq(t, replaced.Accepted, true)
|
||||
eqLabels(t, replaced.Released, "subscription", "publication", "overlap")
|
||||
noOperation(t, routes.Snapshot().Operation)
|
||||
eq(t, edgeOf(t, routes, bootstrap.ChildPeerID).ConnectionID, "recovered_connection")
|
||||
eq(t, routes.CandidateReady(guardFor(bootstrap.ChildPeerID, attempt.Current.Revision, "carrier_sfu"), 3, nil, CandidateProof{}).Accepted, false)
|
||||
next := must(t, routes.Reconcile(4).Operation)
|
||||
eq(t, next.Reason, DemandSfuBootstrap)
|
||||
eq(t, next.DemandPeerID, A)
|
||||
}
|
||||
|
||||
// TS 2569: retains one latest timing sample for a 20-Viewer burst
|
||||
func TestRetainsOneLatestTimingSampleForTwentyViewerBurst(t *testing.T) {
|
||||
routes := newController(2, Options{})
|
||||
|
||||
@@ -2,7 +2,7 @@ package signal
|
||||
|
||||
// Viewer presence, host status, disconnect handling and room termination of
|
||||
// src/server/signaling.ts (sendViewerPresence 1594, broadcastHostStatus
|
||||
// 1683, handleDisconnect 1397, stopSharing 1466, terminateRoom 1500,
|
||||
// 1683, handleDisconnect 1397, stopSharing 1466, closeRoom,
|
||||
// revokeGrantViewers 1016, closeRevokedViewerSession 1697 and the
|
||||
// viewer-keyed map helpers). Every function here runs with mu held.
|
||||
|
||||
@@ -123,8 +123,7 @@ func (s *Server) broadcastHostStatus(roomID string, online, paused bool) {
|
||||
}
|
||||
|
||||
// handleDisconnect ports handleDisconnect. It runs once, from the reader
|
||||
// goroutine's exit (D6). The host lease write is one of the two paths the TS
|
||||
// left uncaught, so its failure panics (D7).
|
||||
// goroutine's exit (D6).
|
||||
func (s *Server) handleDisconnect(sess *session) {
|
||||
if !s.sessions.Has(sess) {
|
||||
return
|
||||
@@ -149,7 +148,7 @@ func (s *Server) handleDisconnect(sess *session) {
|
||||
disconnected, err := s.store.DisconnectParticipant(
|
||||
sess.authenticated.roomID, sess.authenticated.peerID, sess.sessionID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("room store write failed during host disconnect: %w", err))
|
||||
panic(fmt.Errorf("room store failed during disconnect: %w", err))
|
||||
}
|
||||
if disconnected == nil {
|
||||
return
|
||||
@@ -215,26 +214,9 @@ func (s *Server) abandonRoom(roomID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeRoom ports closeRoom.
|
||||
// closeRoom closes the host session first, then
|
||||
// viewers in store order, each told room-closed then closed with 1000.
|
||||
func (s *Server) closeRoom(closed room.ClosedRoom) {
|
||||
s.terminateRoom(closed, "host-ended", "Room abandoned")
|
||||
}
|
||||
|
||||
// expireRooms ports expireRooms, the cleanup timer body. Its store write is
|
||||
// the second path the TS left uncaught (D7).
|
||||
func (s *Server) expireRooms() {
|
||||
expired, err := s.store.ExpireRooms(s.now())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("room store write failed while expiring rooms: %w", err))
|
||||
}
|
||||
for _, closed := range expired {
|
||||
s.terminateRoom(closed, "expired", "Room expired")
|
||||
}
|
||||
}
|
||||
|
||||
// terminateRoom ports terminateRoom (O4: the host session first, then
|
||||
// viewers in store order, each told room-closed then closed with 1000).
|
||||
func (s *Server) terminateRoom(closed room.ClosedRoom, reason, closeText string) {
|
||||
s.clearRoomGraceTimers(closed.RoomID)
|
||||
s.clearRoomViewerEvidence(closed.RoomID)
|
||||
s.router.deleteRoom(closed.RoomID)
|
||||
@@ -244,8 +226,8 @@ func (s *Server) terminateRoom(closed room.ClosedRoom, reason, closeText string)
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
s.send(sess, protocol.RoomClosedMessage{Type: "room-closed", Reason: reason})
|
||||
sess.close(websocket.StatusNormalClosure, closeText)
|
||||
s.send(sess, protocol.RoomClosedMessage{Type: "room-closed", Reason: "host-ended"})
|
||||
sess.close(websocket.StatusNormalClosure, "Room abandoned")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
||||
)
|
||||
|
||||
func TestSignalRebuildSupersedesOverlappingQualityPreparation(t *testing.T) {
|
||||
for _, order := range []string{"prepare-before-restart", "restart-before-prepare"} {
|
||||
t.Run(order, func(t *testing.T) {
|
||||
h := startHarness(t, harnessOptions{endpointMediaCopyCapacity: 2})
|
||||
host := openClient(t, h)
|
||||
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "recovery-host", 1,
|
||||
testShareGeneration, presenceOptions{routePolicy: qualityPolicy})
|
||||
viewer := openClient(t, h)
|
||||
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "recovery-viewer", 0, "", presenceOptions{})
|
||||
commitPreparedRoute(t, viewer)
|
||||
var active activeViewerMediaEdge
|
||||
var hostSession, viewerSession string
|
||||
h.locked(func() {
|
||||
parent, _ := h.store.GetConnectedHost(h.room.RoomID)
|
||||
child, _ := h.store.GetConnectedViewer(h.room.RoomID, viewerAuth.PeerID)
|
||||
hostSession, viewerSession = parent.SessionID, child.SessionID
|
||||
active, _ = h.server.router.resolveActiveViewerMediaEdge(h.room.RoomID, viewerAuth.PeerID)
|
||||
})
|
||||
restart := func() {
|
||||
viewer.sendJSON(map[string]any{"type": "restart-request", "targetPeerId": hostAuth.PeerID,
|
||||
"connectionId": active.connectionID, "rebuild": true})
|
||||
expectMatch(t, host.next("restart-request").raw, `{"rebuild":true}`)
|
||||
}
|
||||
if order == "restart-before-prepare" {
|
||||
restart()
|
||||
}
|
||||
h.locked(func() {
|
||||
participant := authenticatedRouteParticipant{roomID: h.room.RoomID, role: protocol.RoleHost,
|
||||
peerID: hostAuth.PeerID, sessionID: hostSession, routePolicy: qualityPolicy}
|
||||
for index, state := range []string{"healthy", "degraded", "degraded", "degraded"} {
|
||||
h.server.router.observeSenderQualityEvidence(participant, senderEvidenceMessage(
|
||||
viewerAuth.PeerID, active.connectionID, "recovery-sender", "recovery-track",
|
||||
float64(100+index), active.revision, state))
|
||||
}
|
||||
})
|
||||
pending := nextPreparedRoute(t, viewer)
|
||||
if !pending.Candidate.QualityProbe || pending.Assignment.Upstream != protocol.PeerUpstream(hostAuth.PeerID) {
|
||||
t.Fatal("expected a same-parent quality preparation")
|
||||
}
|
||||
if !strings.HasPrefix(active.connectionID, candidateConnectionIDPrefix) ||
|
||||
!strings.HasPrefix(pending.Candidate.ConnectionID, candidateConnectionIDPrefix) {
|
||||
t.Fatal("prepared connections must remain distinguishable after retirement")
|
||||
}
|
||||
if order == "prepare-before-restart" {
|
||||
restart()
|
||||
}
|
||||
|
||||
// A same-parent probe must not intercept the retained connection's
|
||||
// offer, answer or ICE. The probe's own signaling also stays valid.
|
||||
host.sendJSON(signalDescription(viewerAuth.PeerID, active.connectionID, "offer"))
|
||||
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+active.connectionID+`"}}`)
|
||||
viewer.sendJSON(signalDescription(hostAuth.PeerID, active.connectionID, "answer"))
|
||||
host.next("signal")
|
||||
viewer.sendJSON(signalCandidate(hostAuth.PeerID, active.connectionID))
|
||||
host.next("signal")
|
||||
host.sendJSON(signalDescription(viewerAuth.PeerID, pending.Candidate.ConnectionID, "offer"))
|
||||
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+pending.Candidate.ConnectionID+`"}}`)
|
||||
|
||||
const replacement = "manual_replacement_connection"
|
||||
host.sendJSON(signalDescription(viewerAuth.PeerID, replacement, "offer"))
|
||||
expectMatch(t, viewer.next("signal").raw, `{"payload":{"connectionId":"`+replacement+`"}}`)
|
||||
settled := nextActiveRouteAfter(t, viewer, pending.Revision)
|
||||
if settled.Assignment.Upstream != active.upstream {
|
||||
t.Fatal("manual recovery must keep the committed parent")
|
||||
}
|
||||
viewer.sendJSON(signalDescription(hostAuth.PeerID, replacement, "answer"))
|
||||
host.next("signal")
|
||||
|
||||
h.locked(func() {
|
||||
rm, _ := h.server.router.rooms.Get(h.room.RoomID)
|
||||
if rm.controller.Snapshot().Operation != nil {
|
||||
t.Fatal("superseded preparation must release the serial operation")
|
||||
}
|
||||
for _, retired := range []string{pending.Candidate.ConnectionID, active.connectionID} {
|
||||
authorization := h.server.router.peerSignalAuthorization(peerSignalInput{
|
||||
roomID: h.room.RoomID, sourcePeerID: hostAuth.PeerID, sourceSessionID: hostSession,
|
||||
targetPeerID: viewerAuth.PeerID, targetSessionID: viewerSession,
|
||||
connectionID: retired, signalKind: "description", descriptionType: "offer",
|
||||
})
|
||||
if authorization != signalAuthorizationDenied {
|
||||
t.Fatal("a late retired offer must not replace the recovered connection")
|
||||
}
|
||||
}
|
||||
current, _ := h.server.router.resolveActiveViewerMediaEdge(h.room.RoomID, viewerAuth.PeerID)
|
||||
if current.connectionID != replacement || current.revision != settled.Revision {
|
||||
t.Fatalf("recovered edge = %+v", current)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -569,7 +569,12 @@ func (r *router) getViewerRouteUpstream(roomID, viewerPeerID string) protocol.Me
|
||||
return assignmentFor(r.assignments(roomID, rm.controller.Snapshot(), nil, "", ""), viewerPeerID).Upstream
|
||||
}
|
||||
|
||||
// peerSignalAuthorization ports peerSignalAuthorization.
|
||||
// A retired server candidate must never be mistaken for a parent-initiated
|
||||
// replacement. Its ID remains opaque to clients; no retired-ID history is needed.
|
||||
const candidateConnectionIDPrefix = "route_"
|
||||
|
||||
// peerSignalAuthorization resolves candidate and retained media by connection,
|
||||
// since both may belong to the same parent/child pair during overlap.
|
||||
func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorization {
|
||||
rm, _ := r.rooms.Get(input.roomID)
|
||||
if rm == nil || rm.controller == nil {
|
||||
@@ -586,7 +591,7 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
|
||||
input.targetPeerID == operation.ChildPeerID
|
||||
childToParent := input.sourcePeerID == operation.ChildPeerID &&
|
||||
input.targetPeerID == current.Tuple.ParentPeerID
|
||||
if parentToChild || childToParent {
|
||||
if (parentToChild || childToParent) && input.connectionID == current.ConnectionID {
|
||||
parentSession, childSession := input.targetSessionID, input.sourceSessionID
|
||||
if parentToChild {
|
||||
parentSession, childSession = input.sourceSessionID, input.targetSessionID
|
||||
@@ -594,7 +599,6 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
|
||||
if hasParent && hasChild &&
|
||||
parent.SessionID == parentSession &&
|
||||
child.SessionID == childSession &&
|
||||
input.connectionID == current.ConnectionID &&
|
||||
(input.signalKind == "candidate" ||
|
||||
(parentToChild && input.descriptionType == "offer") ||
|
||||
(childToParent && input.descriptionType == "answer")) {
|
||||
@@ -627,6 +631,9 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
|
||||
input.signalKind == "description" &&
|
||||
input.descriptionType == "offer" &&
|
||||
input.connectionID != edge.ConnectionID {
|
||||
if strings.HasPrefix(input.connectionID, candidateConnectionIDPrefix) {
|
||||
return signalAuthorizationDenied
|
||||
}
|
||||
adopted := controller.AdoptDirectConnection(route.AdoptDirectConnectionInput{
|
||||
EdgeGuard: route.EdgeGuard{
|
||||
ChildPeerID: childPeerID,
|
||||
@@ -636,8 +643,13 @@ func (r *router) peerSignalAuthorization(input peerSignalInput) signalAuthorizat
|
||||
ConnectionID: edge.ConnectionID,
|
||||
},
|
||||
NewConnectionID: input.connectionID,
|
||||
})
|
||||
if adopted {
|
||||
}, r.now())
|
||||
if adopted.Accepted {
|
||||
r.releaseResources(adopted.Released)
|
||||
if adopted.ActiveRevision != snapshot.Revision {
|
||||
r.broadcastActive(input.roomID, rm)
|
||||
r.requestPump(input.roomID)
|
||||
}
|
||||
return signalAuthorizationAllowed
|
||||
}
|
||||
return signalAuthorizationDenied
|
||||
|
||||
@@ -308,7 +308,7 @@ func (r *router) prepareCandidate(
|
||||
if !hasChild || child.SessionID != operation.ChildSessionID || shareGeneration == "" {
|
||||
return denied(route.RejectionStale)
|
||||
}
|
||||
connectionID := opaqueID()
|
||||
connectionID := candidateConnectionIDPrefix + opaqueID()
|
||||
var overlap *route.Resource
|
||||
if plan.EndpointTransition.Kind == route.TransitionOverlap {
|
||||
producerPeerID := ""
|
||||
|
||||
@@ -69,14 +69,13 @@ type routerHarness struct {
|
||||
func newRouterHarness(t *testing.T, options routerHarnessOptions) *routerHarness {
|
||||
t.Helper()
|
||||
h := &routerHarness{t: t, sent: map[string][]protocol.ServerMessage{}}
|
||||
storeOptions := room.Options{LeaseMs: 60_000, MaxRooms: 4, MaxViewersPerRoom: 20}
|
||||
storeOptions := room.Options{MaxRooms: 4, MaxViewersPerRoom: 20}
|
||||
var now func() int64
|
||||
var afterFunc func(time.Duration, func()) func() bool
|
||||
if options.fakeTimers {
|
||||
h.clock = &manualClock{settle: h.settle}
|
||||
now = h.clock.now
|
||||
afterFunc = h.clock.afterFunc
|
||||
storeOptions.Now = now
|
||||
}
|
||||
store, err := room.New(storeOptions)
|
||||
if err != nil {
|
||||
@@ -156,10 +155,9 @@ func (h *routerHarness) createRoom() room.CreatedRoom {
|
||||
var created room.CreatedRoom
|
||||
var err error
|
||||
h.locked(func() {
|
||||
var lease int64
|
||||
lease, err = h.store.BeginCreateRoom()
|
||||
err = h.store.BeginCreateRoom()
|
||||
if err == nil {
|
||||
created, err = h.store.CreateRoom(protocol.CodeEntryOpen, nil, nil, "", lease)
|
||||
created, err = h.store.CreateRoom(protocol.CodeEntryOpen, nil, nil, "")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -58,7 +58,6 @@ const (
|
||||
defaultAuthenticationTimeoutMs = 5_000
|
||||
defaultViewerDisconnectGraceMs = 5_000
|
||||
defaultHeartbeatIntervalMs = 30_000
|
||||
defaultCleanupIntervalMs = 30_000
|
||||
)
|
||||
|
||||
// SfuFallback is the embedded media runtime the media router
|
||||
@@ -91,7 +90,6 @@ type Options struct {
|
||||
AuthenticationTimeoutMs int
|
||||
ViewerDisconnectGraceMs int
|
||||
HeartbeatIntervalMs int
|
||||
CleanupIntervalMs int
|
||||
MaxConnections int
|
||||
MaxUnauthenticatedConnections int
|
||||
// AfterFunc is the timer factory (D5); nil uses time.AfterFunc.
|
||||
@@ -138,7 +136,7 @@ type viewerQualityEvidenceAttempt struct {
|
||||
// (authenticate falls back to the session id), which is what made the TS
|
||||
// `undefined === undefined` comparisons meaningful.
|
||||
type roomShare struct {
|
||||
// generation survives stopSharing; only terminateRoom drops the whole
|
||||
// generation survives stopSharing; only closeRoom drops the whole
|
||||
// record (hazard 2).
|
||||
generation string
|
||||
// qualitySettings and routePolicy are nil where the TS map had no entry;
|
||||
@@ -182,7 +180,6 @@ type Server struct {
|
||||
authenticationTimeoutMs int
|
||||
viewerDisconnectGraceMs int
|
||||
heartbeatIntervalMs int
|
||||
cleanupIntervalMs int
|
||||
maxConnections int
|
||||
maxUnauthenticatedConnections int
|
||||
|
||||
@@ -208,12 +205,11 @@ type Server struct {
|
||||
deferredViewerPresenceRooms map[string]struct{}
|
||||
|
||||
heartbeatStop func() bool
|
||||
cleanupStop func() bool
|
||||
closing bool
|
||||
}
|
||||
|
||||
// New is the SignalingServer constructor. It performs no I/O; the heartbeat
|
||||
// and cleanup tickers start immediately.
|
||||
// ticker starts immediately.
|
||||
func New(options Options) (*Server, error) {
|
||||
if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil {
|
||||
return nil, err
|
||||
@@ -239,7 +235,6 @@ func New(options Options) (*Server, error) {
|
||||
authenticationTimeoutMs: orDefault(options.AuthenticationTimeoutMs, defaultAuthenticationTimeoutMs),
|
||||
viewerDisconnectGraceMs: orDefault(options.ViewerDisconnectGraceMs, defaultViewerDisconnectGraceMs),
|
||||
heartbeatIntervalMs: orDefault(options.HeartbeatIntervalMs, defaultHeartbeatIntervalMs),
|
||||
cleanupIntervalMs: orDefault(options.CleanupIntervalMs, defaultCleanupIntervalMs),
|
||||
maxConnections: orDefault(options.MaxConnections, defaultMaxSignalConnections),
|
||||
maxUnauthenticatedConnections: orDefault(options.MaxUnauthenticatedConnections, defaultMaxUnauthenticatedConnections),
|
||||
sessionsByID: map[string]*session{},
|
||||
@@ -295,7 +290,6 @@ func New(options Options) (*Server, error) {
|
||||
s.router = mediaRouter
|
||||
s.mu.Lock()
|
||||
s.armHeartbeat()
|
||||
s.armCleanup()
|
||||
s.mu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
@@ -449,9 +443,6 @@ func (s *Server) Close(ctx context.Context) error {
|
||||
if s.heartbeatStop != nil {
|
||||
s.heartbeatStop()
|
||||
}
|
||||
if s.cleanupStop != nil {
|
||||
s.cleanupStop()
|
||||
}
|
||||
for key, timer := range s.viewerGraceTimers {
|
||||
timer.stop()
|
||||
delete(s.viewerGraceTimers, key)
|
||||
@@ -639,8 +630,7 @@ func (s *Server) CreateRoom(
|
||||
) (room.CreatedRoom, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
lease, err := s.store.BeginCreateRoom()
|
||||
if err != nil {
|
||||
if err := s.store.BeginCreateRoom(); err != nil {
|
||||
return room.CreatedRoom{}, err
|
||||
}
|
||||
var material []byte
|
||||
@@ -652,7 +642,7 @@ func (s *Server) CreateRoom(
|
||||
material, derived = s.store.DeriveViewerPasswordMaterial(*roomPassword, nil)
|
||||
}()
|
||||
}
|
||||
return s.store.CreateRoom(codeEntryPolicy, material, derived, preferredRoomID, lease)
|
||||
return s.store.CreateRoom(codeEntryPolicy, material, derived, preferredRoomID)
|
||||
}
|
||||
|
||||
// armHeartbeat is the heartbeat setInterval (T1): each tick re-arms the
|
||||
@@ -674,19 +664,6 @@ func (s *Server) armHeartbeat() {
|
||||
})
|
||||
}
|
||||
|
||||
// armCleanup is the expireRooms setInterval (T2).
|
||||
func (s *Server) armCleanup() {
|
||||
s.cleanupStop = s.afterFunc(time.Duration(s.cleanupIntervalMs)*time.Millisecond, func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closing {
|
||||
return
|
||||
}
|
||||
s.expireRooms()
|
||||
s.armCleanup()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// messages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -911,7 +888,7 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
|
||||
if s.closing || !s.sessions.Has(sess) || sess.revoked || !sess.open() {
|
||||
if _, err := s.store.DisconnectParticipant(participant.RoomID, participant.PeerID, sess.sessionID); err != nil {
|
||||
// The TS threw out of the async function; there is no catcher (D7).
|
||||
panic(fmt.Errorf("room store write failed while releasing an aborted authentication: %w", err))
|
||||
panic(fmt.Errorf("room store failed while releasing an aborted authentication: %w", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -985,7 +962,6 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
|
||||
Type: "authenticated",
|
||||
Protocol: protocol.SignalingProtocol,
|
||||
PeerID: participant.PeerID,
|
||||
RoomExpiresAt: participant.ExpiresAt,
|
||||
MaxViewers: protocol.Int(s.store.MaxViewersPerRoom()),
|
||||
EndpointMediaCopyCapacity: protocol.Int(s.endpointMediaCopyCapacity),
|
||||
HostOnline: participant.HostOnline,
|
||||
@@ -1009,7 +985,6 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
|
||||
Type: "authenticated",
|
||||
Protocol: protocol.SignalingProtocol,
|
||||
PeerID: participant.PeerID,
|
||||
RoomExpiresAt: participant.ExpiresAt,
|
||||
MaxViewers: protocol.Int(s.store.MaxViewersPerRoom()),
|
||||
EndpointMediaCopyCapacity: protocol.Int(s.endpointMediaCopyCapacity),
|
||||
HostOnline: participant.HostOnline,
|
||||
@@ -1306,7 +1281,7 @@ func (s *Server) handleAuthenticatedMessage(sess *session, authenticated *authen
|
||||
if s.sessions.Has(sess) && sess.authenticated == authenticated {
|
||||
if _, err := s.store.DisconnectParticipant(authenticated.roomID, authenticated.peerID, sess.sessionID); err != nil {
|
||||
// The TS threw out of the message handler; nothing caught it (D7).
|
||||
panic(fmt.Errorf("room store write failed while stopping sharing: %w", err))
|
||||
panic(fmt.Errorf("room store failed while stopping sharing: %w", err))
|
||||
}
|
||||
}
|
||||
s.stopSharing(authenticated.roomID)
|
||||
@@ -1643,15 +1618,13 @@ func authenticationErrorMessage(code string) string {
|
||||
case "ROOM_ACCESS_DENIED":
|
||||
return "Room access denied"
|
||||
case "ROOM_NOT_FOUND":
|
||||
return "Room not found or expired"
|
||||
case "ROOM_EXPIRED":
|
||||
return "Room has expired"
|
||||
return "Room not found"
|
||||
case "ROOM_FULL":
|
||||
return "Room is full"
|
||||
case "HOST_ALREADY_CONNECTED":
|
||||
return "A host is already connected"
|
||||
case "INVALID_TOKEN":
|
||||
return "Room is invalid or expired"
|
||||
return "Room credentials are invalid"
|
||||
}
|
||||
return "Authentication failed"
|
||||
}
|
||||
@@ -1672,12 +1645,12 @@ func authenticationErrorCode(request authRequest, err error) string {
|
||||
return string(roomError.Code)
|
||||
}
|
||||
if request.viewerGrant == "" {
|
||||
if roomError.Code == room.CodeRoomNotFound || roomError.Code == room.CodeRoomExpired {
|
||||
if roomError.Code == room.CodeRoomNotFound {
|
||||
return "ROOM_NOT_FOUND"
|
||||
}
|
||||
return "ROOM_ACCESS_DENIED"
|
||||
}
|
||||
if roomError.Code == room.CodeRoomExpired || roomError.Code == room.CodeRoomNotFound {
|
||||
if roomError.Code == room.CodeRoomNotFound {
|
||||
return "INVALID_TOKEN"
|
||||
}
|
||||
return string(roomError.Code)
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
|
||||
for _, password := range []string{"", "room-password"} {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
store, err := room.New(room.Options{
|
||||
LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 1,
|
||||
MaxRooms: 1, MaxViewersPerRoom: 1,
|
||||
Random: func(int) []byte { panic("test random source") },
|
||||
})
|
||||
if err != nil {
|
||||
@@ -71,46 +71,27 @@ func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingStorageFailureReleasesLockWithoutSuppressingPanic(t *testing.T) {
|
||||
func TestStopSharingDoesNotWriteRoomAuthority(t *testing.T) {
|
||||
database, err := room.NewDatabase(filepath.Join(t.TempDir(), "rooms.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := newStore(t, 1, nil, database)
|
||||
defer store.Close()
|
||||
created := createStoreRoom(t, store, protocol.CodeEntryOpen, "")
|
||||
sess := newSession(nil, true)
|
||||
defer sess.cancel()
|
||||
participant, err := store.ConnectParticipant(room.ConnectParticipantInput{
|
||||
RoomID: created.RoomID, Role: protocol.RoleHost, Token: created.HostToken,
|
||||
ClientID: "failed-storage-host", SessionID: sess.sessionID,
|
||||
store := newStore(t, 1, database)
|
||||
h := startHarness(t, harnessOptions{store: store})
|
||||
host := openClient(t, h)
|
||||
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "storage-free-host", 1, "", presenceOptions{})
|
||||
h.locked(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
host.sendJSON(map[string]any{"type": "stop-sharing", "shareGeneration": authenticated.ShareGeneration})
|
||||
expectClose(t, host, 1000, "Sharing stopped")
|
||||
if h.storeSize() != 1 {
|
||||
t.Fatal("stopping sharing removed room authority")
|
||||
}
|
||||
const generation = "failed_storage_share_generation"
|
||||
sess.authenticated = &authenticatedSession{roomID: created.RoomID, role: protocol.RoleHost,
|
||||
peerID: participant.PeerID, shareGeneration: generation}
|
||||
server := &Server{store: store, shares: map[string]roomShare{created.RoomID: {generation: generation}}}
|
||||
server.sessions.Set(sess, struct{}{})
|
||||
if err = database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
failure, ok := recover().(error)
|
||||
if !ok || !strings.Contains(failure.Error(), "room store write failed while stopping sharing") {
|
||||
t.Errorf("handler did not preserve its storage panic: %v", failure)
|
||||
}
|
||||
}()
|
||||
server.handleIncoming(sess, websocket.MessageText,
|
||||
[]byte(`{"type":"stop-sharing","shareGeneration":"`+generation+`"}`))
|
||||
}()
|
||||
if !server.mu.TryLock() {
|
||||
server.mu.Unlock() // Allow test cleanup if the regression returns.
|
||||
t.Fatal("storage panic left the message lock held before reader cleanup")
|
||||
}
|
||||
server.mu.Unlock()
|
||||
resumed := openClient(t, h)
|
||||
authenticate(t, resumed, h.room, protocol.RoleHost, "storage-free-host", 1, "", presenceOptions{})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,7 +109,6 @@ type harnessOptions struct {
|
||||
stunURLs []string
|
||||
natPredictionEnabled bool
|
||||
now func() int64
|
||||
cleanupIntervalMs int
|
||||
store *room.Store
|
||||
room *room.CreatedRoom
|
||||
afterFunc func(time.Duration, func()) func() bool
|
||||
@@ -152,13 +132,11 @@ func orValue(value, fallback int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
func newStore(t *testing.T, maxViewersPerRoom int, now func() int64, database *room.Database) *room.Store {
|
||||
func newStore(t *testing.T, maxViewersPerRoom int, database *room.Database) *room.Store {
|
||||
t.Helper()
|
||||
store, err := room.New(room.Options{
|
||||
LeaseMs: 86_400_000,
|
||||
MaxRooms: room.Capacity,
|
||||
MaxViewersPerRoom: maxViewersPerRoom,
|
||||
Now: now,
|
||||
Database: database,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -175,8 +153,7 @@ func newStore(t *testing.T, maxViewersPerRoom int, now func() int64, database *r
|
||||
// createStoreRoom is roomStore.createRoom() on a store no server guards yet.
|
||||
func createStoreRoom(t *testing.T, store *room.Store, policy protocol.CodeEntryPolicy, password string) room.CreatedRoom {
|
||||
t.Helper()
|
||||
lease, err := store.BeginCreateRoom()
|
||||
if err != nil {
|
||||
if err := store.BeginCreateRoom(); err != nil {
|
||||
t.Fatalf("BeginCreateRoom: %v", err)
|
||||
}
|
||||
var material []byte
|
||||
@@ -184,7 +161,7 @@ func createStoreRoom(t *testing.T, store *room.Store, policy protocol.CodeEntryP
|
||||
if password != "" {
|
||||
material, derived = store.DeriveViewerPasswordMaterial(password, nil)
|
||||
}
|
||||
created, err := store.CreateRoom(policy, material, derived, "", lease)
|
||||
created, err := store.CreateRoom(policy, material, derived, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
@@ -196,7 +173,7 @@ func startHarness(t *testing.T, options harnessOptions) *harness {
|
||||
maxViewersPerRoom := orValue(options.maxViewersPerRoom, 8)
|
||||
store := options.store
|
||||
if store == nil {
|
||||
store = newStore(t, maxViewersPerRoom, options.now, nil)
|
||||
store = newStore(t, maxViewersPerRoom, nil)
|
||||
}
|
||||
var created room.CreatedRoom
|
||||
if options.room != nil {
|
||||
@@ -227,7 +204,6 @@ func startHarness(t *testing.T, options harnessOptions) *harness {
|
||||
AuthenticationTimeoutMs: orValue(options.authenticationTimeoutMs, 500),
|
||||
ViewerDisconnectGraceMs: orValue(options.viewerDisconnectGraceMs, 50),
|
||||
HeartbeatIntervalMs: 60_000,
|
||||
CleanupIntervalMs: orValue(options.cleanupIntervalMs, 60_000),
|
||||
MaxConnections: options.maxSignalConnections,
|
||||
MaxUnauthenticatedConnections: options.maxUnauthenticatedSignalConnections,
|
||||
AfterFunc: options.afterFunc,
|
||||
@@ -1163,7 +1139,7 @@ func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testin
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstStore := newStore(t, 8, nil, firstDatabase)
|
||||
firstStore := newStore(t, 8, firstDatabase)
|
||||
rm := createStoreRoom(t, firstStore, protocol.CodeEntryOpen, "")
|
||||
first := startHarness(t, harnessOptions{store: firstStore, room: &rm})
|
||||
firstHost := openClient(t, first)
|
||||
@@ -1178,7 +1154,7 @@ func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testin
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondStore := newStore(t, 8, nil, secondDatabase)
|
||||
secondStore := newStore(t, 8, secondDatabase)
|
||||
second := startHarness(t, harnessOptions{store: secondStore, room: &rm})
|
||||
secondViewer := openClient(t, second)
|
||||
viewerAuth := authenticate(t, secondViewer, rm, protocol.RoleViewer, "restart-route-viewer", 1, "", presenceOptions{})
|
||||
@@ -1913,11 +1889,11 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
|
||||
})
|
||||
|
||||
protectedRoom := h.createRoom(protocol.CodeEntryPrivate, "correct-password")
|
||||
expiring := h.createRoom(protocol.CodeEntryOpen, "")
|
||||
abandoned := h.createRoom(protocol.CodeEntryOpen, "")
|
||||
unusedRoomID := ""
|
||||
for code := 1_000; code < 10_000; code++ {
|
||||
candidate := fmt.Sprint(code)
|
||||
if candidate != h.room.RoomID && candidate != protectedRoom.RoomID && candidate != expiring.RoomID {
|
||||
if candidate != h.room.RoomID && candidate != protectedRoom.RoomID && candidate != abandoned.RoomID {
|
||||
unusedRoomID = candidate
|
||||
break
|
||||
}
|
||||
@@ -1936,7 +1912,7 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
|
||||
sendCodeOnlyAuthenticate(client, roomID, clientID, extra)
|
||||
message := "Room access denied"
|
||||
if code == "ROOM_NOT_FOUND" {
|
||||
message = "Room not found or expired"
|
||||
message = "Room not found"
|
||||
}
|
||||
expectEqual(t, client.next("error").raw, fmt.Sprintf(`{"type":"error","code":%q,"message":%q}`, code, message))
|
||||
expectClose(t, client, 4003, "Authentication failed")
|
||||
@@ -1947,85 +1923,50 @@ func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testi
|
||||
expectDenial(protectedRoom.RoomID, "wrong-password-viewer", "ROOM_ACCESS_DENIED", "wrong-password")
|
||||
expectDenial(h.room.RoomID, "full-room-viewer", "ROOM_ACCESS_DENIED", "")
|
||||
|
||||
now.advance(86_400_001)
|
||||
expectDenial(expiring.RoomID, "expired-room-viewer", "ROOM_NOT_FOUND", "")
|
||||
|
||||
expiredGrant := openClientWithCookie(t, h, siteAccessCookie)
|
||||
sendCodeOnlyAuthenticate(expiredGrant, expiring.RoomID, "expired-grant-viewer",
|
||||
map[string]any{"viewerGrant": expiring.ViewerGrant})
|
||||
expectMatch(t, expiredGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// room lease and expiry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSignalDoesNotExpireActiveRoomAndArmsItsLeaseAfterHostDisconnect(t *testing.T) {
|
||||
now := newClock(utcMillis(2026, time.August, 20, 12))
|
||||
h := startHarness(t, harnessOptions{now: now.now})
|
||||
host := openClient(t, h)
|
||||
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "active-lease-host", 1, "", presenceOptions{})
|
||||
viewer := openClient(t, h)
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "provisional-viewer", 1, "", presenceOptions{})
|
||||
commitPreparedRoute(t, viewer)
|
||||
|
||||
now.advance(86_401_000)
|
||||
expireAt := func(nowMs int64) []room.ClosedRoom {
|
||||
t.Helper()
|
||||
var expired []room.ClosedRoom
|
||||
var err error
|
||||
h.locked(func() { expired, err = h.store.ExpireRooms(nowMs) })
|
||||
if err != nil {
|
||||
t.Fatalf("ExpireRooms: %v", err)
|
||||
h.locked(func() {
|
||||
if _, err := h.store.AbandonRoom(abandoned.RoomID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return expired
|
||||
}
|
||||
if expired := expireAt(now.now()); len(expired) != 0 {
|
||||
t.Fatalf("active room expired: %v", expired)
|
||||
}
|
||||
h.closeClient(host)
|
||||
viewer.next("host-status")
|
||||
var hostConnected bool
|
||||
h.locked(func() { _, hostConnected = h.store.GetConnectedHost(h.room.RoomID) })
|
||||
expectTrue(t, "host must be disconnected", !hostConnected)
|
||||
if expired := expireAt(now.now() + 86_399_999); len(expired) != 0 {
|
||||
t.Fatalf("room expired before its lease: %v", expired)
|
||||
}
|
||||
expired := expireAt(now.now() + 86_401_000)
|
||||
if len(expired) != 1 || expired[0].RoomID != h.room.RoomID || len(expired[0].SessionIDs) != 1 {
|
||||
t.Fatalf("expired rooms: %+v", expired)
|
||||
}
|
||||
h.closeClient(viewer)
|
||||
expectString(t, "role", authenticated.Role, protocol.RoleHost)
|
||||
})
|
||||
expectDenial(abandoned.RoomID, "abandoned-room-viewer", "ROOM_NOT_FOUND", "")
|
||||
|
||||
abandonedGrant := openClientWithCookie(t, h, siteAccessCookie)
|
||||
sendCodeOnlyAuthenticate(abandonedGrant, abandoned.RoomID, "abandoned-grant-viewer",
|
||||
map[string]any{"viewerGrant": abandoned.ViewerGrant})
|
||||
expectMatch(t, abandonedGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
||||
}
|
||||
|
||||
func TestSignalTerminatesExpiredDormantRoomThroughCleanupTimer(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// room lifetime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSignalRetainsDormantAuthorityAcrossLongHostAbsence(t *testing.T) {
|
||||
now := newClock(utcMillis(2026, time.August, 20, 12))
|
||||
h := startHarness(t, harnessOptions{now: now.now, cleanupIntervalMs: 20})
|
||||
h := startHarness(t, harnessOptions{now: now.now})
|
||||
now.advance(30 * 24 * 60 * 60 * 1_000)
|
||||
host := openClient(t, h)
|
||||
authenticate(t, host, h.room, protocol.RoleHost, "expiring-host", 1, "", presenceOptions{})
|
||||
authenticate(t, host, h.room, protocol.RoleHost, "returning-host", 1, "", presenceOptions{})
|
||||
viewer := openClient(t, h)
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "expiring-viewer", 1, "", presenceOptions{})
|
||||
commitPreparedRoute(t, viewer)
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "waiting-viewer", 1, "", presenceOptions{})
|
||||
h.closeClient(host)
|
||||
viewer.next("host-status")
|
||||
|
||||
now.advance(86_401_000)
|
||||
|
||||
expectEqual(t, viewer.next("room-closed").raw, `{"type":"room-closed","reason":"expired"}`)
|
||||
expectClose(t, viewer, 1000, "Room expired")
|
||||
if size := h.storeSize(); size != 0 {
|
||||
t.Fatalf("store size %d", size)
|
||||
now.advance(30 * 24 * 60 * 60 * 1_000)
|
||||
resumed := openClient(t, h)
|
||||
authenticate(t, resumed, h.room, protocol.RoleHost, "returning-host", 1, "", presenceOptions{})
|
||||
lateViewer := openClient(t, h)
|
||||
authenticate(t, lateViewer, h.room, protocol.RoleViewer, "late-viewer", 1, "", presenceOptions{})
|
||||
if h.storeSize() != 1 || viewer.isClosed() {
|
||||
t.Fatal("host absence retired the room or its Viewer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalKeepsPreHostViewerSessionCurrentAfterHostClearsRoomLease(t *testing.T) {
|
||||
func TestSignalKeepsPreHostViewerSessionCurrentAfterHostConnects(t *testing.T) {
|
||||
now := newClock(utcMillis(2026, time.August, 20, 12))
|
||||
h := startHarness(t, harnessOptions{now: now.now})
|
||||
viewer := openClient(t, h)
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "pre-host-lease-viewer", 1, "", presenceOptions{displayName: "Before"})
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "pre-host-viewer", 1, "", presenceOptions{displayName: "Before"})
|
||||
host := openClient(t, h)
|
||||
authenticate(t, host, h.room, protocol.RoleHost, "pre-host-lease-host", 1, "", presenceOptions{viewerPresence: true})
|
||||
authenticate(t, host, h.room, protocol.RoleHost, "pre-host-host", 1, "", presenceOptions{viewerPresence: true})
|
||||
named := func(name string) func(presenceMessage) bool {
|
||||
return func(message presenceMessage) bool {
|
||||
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
||||
@@ -2055,7 +1996,7 @@ func TestSignalLeavesEstablishedAuthorizationAndMediaUntouchedWhenGrantUpdateFai
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := newStore(t, 8, nil, database)
|
||||
store := newStore(t, 8, database)
|
||||
h := startHarness(t, harnessOptions{store: store})
|
||||
host := openClient(t, h)
|
||||
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "persistence-failure-host", 1, "", presenceOptions{})
|
||||
@@ -2676,7 +2617,7 @@ func (m *timerQueue) fireWhere(due func(time.Duration) bool) {
|
||||
|
||||
func TestSignalHeartbeatTerminatesSilentConnectionAfterMissedPong(t *testing.T) {
|
||||
timers := &timerQueue{}
|
||||
h := startHarness(t, harnessOptions{afterFunc: timers.afterFunc, cleanupIntervalMs: 30_000})
|
||||
h := startHarness(t, harnessOptions{afterFunc: timers.afterFunc})
|
||||
heartbeat := 60_000 * time.Millisecond
|
||||
responsive := openClient(t, h)
|
||||
header := http.Header{}
|
||||
|
||||
Reference in new issue
Block a user