package handlers import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/Silo-Server/silo-server/internal/access" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/settingscontract" "github.com/Silo-Server/silo-server/internal/userdb" "github.com/Silo-Server/silo-server/internal/userstore" ) func newDevicesTestHandler(t *testing.T) (*DeviceHandler, userstore.UserStore) { t.Helper() dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + "?mode=memory&cache=shared" db, err := sql.Open("sqlite3", dsn) if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := userdb.InitSchema(db); err != nil { t.Fatalf("init schema: %v", err) } store := userdb.NewSQLiteUserStore(db) ctx := context.Background() for _, p := range []userstore.Profile{ {ID: "profile-1", Name: "Sam", IsPrimary: true}, {ID: "profile-2", Name: "Robin"}, } { if err := store.CreateProfile(ctx, p); err != nil { t.Fatalf("create profile %s: %v", p.ID, err) } } return NewDeviceHandler(testUserStoreProvider{store: store}), store } func seedDevice(t *testing.T, store userstore.UserStore, profileID, deviceID, name string) { t.Helper() registry, ok := store.(userstore.DeviceRegistry) if !ok { t.Fatal("store does not implement DeviceRegistry") } if err := registry.RegisterDevice(context.Background(), userstore.DeviceEntry{ ProfileID: profileID, DeviceID: deviceID, DeviceName: name, DevicePlatform: "web", }); err != nil { t.Fatalf("registering %s: %v", deviceID, err) } } func seedDeviceValue(t *testing.T, store userstore.UserStore, profileID, deviceID, key, value string) { t.Helper() if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{ Key: key, Scope: settingscontract.ScopeProfileDevice, ProfileID: profileID, DeviceID: deviceID, }, json.RawMessage(value)); err != nil { t.Fatalf("seeding %s on %s: %v", key, deviceID, err) } } func devicesRequest(method, target, profileID string) *http.Request { req := httptest.NewRequest(method, target, nil) req.Header.Set(deviceIDHeader, "device-1") ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1}) return req.WithContext(apimw.SetProfileID(ctx, profileID)) } func listDevices(t *testing.T, h *DeviceHandler, query, profileID string) deviceListResponse { t.Helper() target := "/devices" if query != "" { target += "?" + query } rec := httptest.NewRecorder() h.HandleListDevices(rec, devicesRequest(http.MethodGet, target, profileID)) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d: %s", target, rec.Code, rec.Body.String()) } var body deviceListResponse if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decoding: %v", err) } return body } // TestListDevices_FiltersToCallingProfile is the security test for this // endpoint. ListDevices is account-wide by construction in both backends — // "WHERE user_id" in Postgres and no WHERE at all in the per-user SQLite — so a // passthrough would show every household member's devices to everyone. func TestListDevices_FiltersToCallingProfile(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-1", "Sam's laptop") seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") body := listDevices(t, handler, "", "profile-1") if len(body.Devices) != 1 { t.Fatalf("returned %d devices, want 1: %+v", len(body.Devices), body.Devices) } if body.Devices[0].DeviceID != "device-1" { t.Errorf("returned device %q, want device-1", body.Devices[0].DeviceID) } for _, device := range body.Devices { if device.ProfileID != "profile-1" { t.Errorf("leaked device %q from profile %q", device.DeviceID, device.ProfileID) } } } func TestListDevices_CountsChangedSettings(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-1", "Laptop") seedDevice(t, store, "profile-1", "device-2", "Apple TV") seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`) seedDeviceValue(t, store, "profile-1", "device-2", "playback.subtitle_mode", `"always"`) // Another profile's row on the same device must not be counted. seedDeviceValue(t, store, "profile-2", "device-2", "player.seek_cache_enabled", `false`) body := listDevices(t, handler, "", "profile-1") counts := map[string]int{} for _, device := range body.Devices { counts[device.DeviceID] = device.ChangedCount } if counts["device-2"] != 2 { t.Errorf("device-2 changed_count = %d, want 2", counts["device-2"]) } if counts["device-1"] != 0 { t.Errorf("device-1 changed_count = %d, want 0", counts["device-1"]) } } func TestListDevices_MarksCurrentDevice(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-1", "This browser") seedDevice(t, store, "profile-1", "device-2", "Apple TV") body := listDevices(t, handler, "", "profile-1") for _, device := range body.Devices { want := device.DeviceID == "device-1" if device.IsCurrentDevice != want { t.Errorf("device %q is_current_device = %v, want %v", device.DeviceID, device.IsCurrentDevice, want) } } } func TestListDevices_IncludesProfileName(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-1", "Laptop") body := listDevices(t, handler, "", "profile-1") if len(body.Devices) != 1 || body.Devices[0].ProfileName != "Sam" { t.Errorf("profile_name = %q, want Sam", body.Devices[0].ProfileName) } } func routeDevice( t *testing.T, h *DeviceHandler, method, target, deviceID, profileID string, handle func(http.ResponseWriter, *http.Request), ) *httptest.ResponseRecorder { t.Helper() req := devicesRequest(method, target, profileID) routeCtx := chi.NewRouteContext() routeCtx.URLParams.Add("device_id", deviceID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)) rec := httptest.NewRecorder() handle(rec, req) return rec } func TestForgetDevice_RemovesSettingsAndRegistryRow(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-2", "Apple TV") seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`) rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2", "profile-1", handler.HandleForgetDevice) if rec.Code != http.StatusNoContent { t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String()) } registry := store.(userstore.DeviceRegistry) exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2") if err != nil { t.Fatalf("DeviceExists: %v", err) } if exists { t.Error("registry row survived forget") } if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" { t.Errorf("setting row survived forget on device %q", got) } } func TestForgetDevice_RejectsOtherProfilesDevice(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`) rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9", "device-9", "profile-1", handler.HandleForgetDevice) if rec.Code != http.StatusNotFound { t.Fatalf("DELETE another profile's device = %d, want 404", rec.Code) } registry := store.(userstore.DeviceRegistry) exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9") if err != nil { t.Fatalf("DeviceExists: %v", err) } if !exists { t.Error("another profile's device was removed") } if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "device-9" { t.Errorf("another profile's setting row was removed (device %q)", got) } } // A repeated forget reports 404, not 500 or a partial delete: once the device // is gone this profile has no trace of it, which is indistinguishable from a // device that was never here — and deliberately so, since the same answer is // what keeps another profile's device ids from being probeable. func TestForgetDevice_SecondCallIsNotFound(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-2", "Apple TV") if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2", "profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNoContent { t.Fatalf("first DELETE = %d, want 204: %s", rec.Code, rec.Body.String()) } if rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2", "device-2", "profile-1", handler.HandleForgetDevice); rec.Code != http.StatusNotFound { t.Fatalf("second DELETE = %d, want 404", rec.Code) } } // Forgetting a device two profiles share removes only the caller's half. func TestForgetDevice_LeavesOtherProfilesRowOnSharedDevice(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "shared-tv", "Living Room TV") seedDevice(t, store, "profile-2", "shared-tv", "Living Room TV") seedDeviceValue(t, store, "profile-2", "shared-tv", "player.hdr_enabled", `false`) rec := routeDevice(t, handler, http.MethodDelete, "/devices/shared-tv", "shared-tv", "profile-1", handler.HandleForgetDevice) if rec.Code != http.StatusNoContent { t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String()) } registry := store.(userstore.DeviceRegistry) stillThere, err := registry.DeviceExists(context.Background(), "profile-2", "shared-tv") if err != nil { t.Fatalf("DeviceExists: %v", err) } if !stillThere { t.Error("forgetting one profile's half removed the other profile's row") } if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "shared-tv" { t.Errorf("the other profile's setting row was removed (device %q)", got) } } func TestClearDeviceSettings_KeepsRegistryRow(t *testing.T) { handler, store := newDevicesTestHandler(t) seedDevice(t, store, "profile-1", "device-2", "Apple TV") seedDeviceValue(t, store, "profile-1", "device-2", "player.hdr_enabled", `false`) rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-2/settings", "device-2", "profile-1", handler.HandleClearDeviceSettings) if rec.Code != http.StatusNoContent { t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String()) } registry := store.(userstore.DeviceRegistry) exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-2") if err != nil { t.Fatalf("DeviceExists: %v", err) } if !exists { t.Error("registry row was removed; clear must keep the device") } if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" { t.Errorf("setting row survived clear on device %q", got) } } // --- Household scope --- func householdDevicesHandler(t *testing.T) (*DeviceHandler, userstore.UserStore) { t.Helper() handler, store := newDevicesTestHandler(t) handler.UserRepo = stubUserRepo{user: &models.User{ID: 1}} handler.ProfileTokens = access.NewProfileTokenService("test-secret-value-at-least-32-chars", 0) return handler, store } func TestListDevices_PrimarySeesHouseholdWhenRequested(t *testing.T) { handler, store := householdDevicesHandler(t) seedDevice(t, store, "profile-1", "device-1", "Sam's laptop") seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") body := listDevices(t, handler, "scope=household", "profile-1") if len(body.Devices) != 2 { t.Fatalf("household scope returned %d devices, want 2: %+v", len(body.Devices), body.Devices) } names := map[string]string{} for _, device := range body.Devices { names[device.DeviceID] = device.ProfileName } if names["device-9"] != "Robin" { t.Errorf("device-9 profile_name = %q, want Robin", names["device-9"]) } } func TestListDevices_NonPrimaryCannotRequestHousehold(t *testing.T) { handler, store := householdDevicesHandler(t) seedDevice(t, store, "profile-1", "device-1", "Sam's laptop") seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") rec := httptest.NewRecorder() handler.HandleListDevices(rec, devicesRequest(http.MethodGet, "/devices?scope=household", "profile-2")) if rec.Code != http.StatusForbidden { t.Fatalf("non-primary household read = %d, want 403: %s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "device-1") { t.Error("refusal leaked another profile's device") } } // Default scope stays private even for the household parent, so the ordinary // screen cannot show the family's devices by forgetting to ask for less. func TestListDevices_PrimaryDefaultsToOwnProfile(t *testing.T) { handler, store := householdDevicesHandler(t) seedDevice(t, store, "profile-1", "device-1", "Sam's laptop") seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") body := listDevices(t, handler, "", "profile-1") if len(body.Devices) != 1 || body.Devices[0].DeviceID != "device-1" { t.Fatalf("default scope returned %+v, want only device-1", body.Devices) } } func TestForgetDevice_PrimaryMayForgetHouseholdDevice(t *testing.T) { handler, store := householdDevicesHandler(t) seedDevice(t, store, "profile-2", "device-9", "Robin's iPad") seedDeviceValue(t, store, "profile-2", "device-9", "player.hdr_enabled", `false`) rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-9?profile_id=profile-2", "device-9", "profile-1", handler.HandleForgetDevice) if rec.Code != http.StatusNoContent { t.Fatalf("primary forgetting a household device = %d: %s", rec.Code, rec.Body.String()) } registry := store.(userstore.DeviceRegistry) exists, err := registry.DeviceExists(context.Background(), "profile-2", "device-9") if err != nil { t.Fatalf("DeviceExists: %v", err) } if exists { t.Error("device survived the household forget") } } func TestForgetDevice_NonPrimaryCannotForgetSiblingsDevice(t *testing.T) { handler, store := householdDevicesHandler(t) seedDevice(t, store, "profile-1", "device-1", "Sam's laptop") rec := routeDevice(t, handler, http.MethodDelete, "/devices/device-1?profile_id=profile-1", "device-1", "profile-2", handler.HandleForgetDevice) if rec.Code != http.StatusForbidden { t.Fatalf("non-primary forgetting a sibling's device = %d, want 403: %s", rec.Code, rec.Body.String()) } registry := store.(userstore.DeviceRegistry) exists, err := registry.DeviceExists(context.Background(), "profile-1", "device-1") if err != nil { t.Fatalf("DeviceExists: %v", err) } if !exists { t.Error("a non-primary profile removed a sibling's device") } }