406 lines
14 KiB
Go
406 lines
14 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|