* fix(profiles): enforce per-account profile name uniqueness Profile create and rename accepted any name, so one account could hold unlimited profiles all called "Laura" (every client allowed it too). Reject a create or rename whose trimmed, case-insensitive name matches another profile on the same account with 409 name_conflict. Scoping is per account by construction — the check runs against a single user's profile store, so different accounts can still each have a "Laura". Renames exclude the profile being updated, so re-saving a profile under its own name (e.g. avatar-only edits that resubmit the name) still works. Also reject whitespace-only names on create and rename; a name of " " previously passed the blank check. Additive-only per the v1 API rules: new 409 error code on existing endpoints, following the profile_limit_reached pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(profiles): store the trimmed profile name The conflict check compared trimmed names but create/rename persisted the raw input, so " Laura " could land with stray whitespace and render inconsistently. Normalize to the trimmed form before storage on both paths. Addresses the CodeRabbit review finding on PR #342. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(profiles): cover whitespace-only rejection and rename trimming Also document the check-then-write race in profileNameConflicts: the userstore backends carry no unique index on name, so concurrent creates can still race past the guard, same as profile_limit_reached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
794 lines
21 KiB
Go
794 lines
21 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/userdb"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
type testProfileUserRepo struct {
|
|
user *models.User
|
|
err error
|
|
}
|
|
|
|
func (r testProfileUserRepo) GetByID(context.Context, int) (*models.User, error) {
|
|
return r.user, r.err
|
|
}
|
|
|
|
func newAuthorizedProfileRequest(body string) *http.Request {
|
|
return newAuthorizedProfileRequestWithRole(http.MethodPost, "/profiles", body, "user", "")
|
|
}
|
|
|
|
func newAuthorizedProfileRequestWithRole(
|
|
method, path, body, role, activeProfileID string,
|
|
) *http.Request {
|
|
return newAuthorizedProfileRequestWithSession(method, path, body, role, activeProfileID, "")
|
|
}
|
|
|
|
func newAuthorizedProfileRequestWithSession(
|
|
method, path, body, role, activeProfileID, sessionID string,
|
|
) *http.Request {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
|
|
UserID: 1,
|
|
Role: role,
|
|
SessionID: sessionID,
|
|
TokenType: auth.TokenTypeAccess,
|
|
})
|
|
if activeProfileID != "" {
|
|
ctx = apimw.SetProfileID(ctx, activeProfileID)
|
|
}
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
func withProfileRouteParam(req *http.Request, key, value string) *http.Request {
|
|
routeCtx := chi.NewRouteContext()
|
|
routeCtx.URLParams.Add(key, value)
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
|
}
|
|
|
|
func newProfileTestStore(t *testing.T) userstore.UserStore {
|
|
return newProfileTestStoreWithSeed(t, true)
|
|
}
|
|
|
|
func newEmptyProfileTestStore(t *testing.T) userstore.UserStore {
|
|
return newProfileTestStoreWithSeed(t, false)
|
|
}
|
|
|
|
func newProfileTestStoreWithSeed(t *testing.T, seedProfile bool) 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)
|
|
if seedProfile {
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
func TestHandleCreateProfile_EnforcesUserProfileLimit(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 1},
|
|
}
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":"Kids","max_playback_quality":"1080p"}`,
|
|
"admin",
|
|
"",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp errorResponse
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Error != "profile_limit_reached" {
|
|
t.Fatalf("error = %q, want %q", resp.Error, "profile_limit_reached")
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("profile count = %d, want 1", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_AllowsNonAdminUpToCap(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 5},
|
|
}
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":"Kids"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 2 {
|
|
t.Fatalf("profile count = %d, want 2", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_RejectsDuplicateName(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
// The seeded store already holds "Main"; a trimmed, case-insensitive
|
|
// match must be rejected within the same account.
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":" main "}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp errorResponse
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Error != "name_conflict" {
|
|
t.Fatalf("error = %q, want %q", resp.Error, "name_conflict")
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("profile count = %d, want 1", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_RejectsWhitespaceOnlyName(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":" "}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("profile count = %d, want 1", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_TrimsStoredName(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":" Laura "}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
for _, p := range profiles {
|
|
if p.Name == "Laura" {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("no profile stored with trimmed name, profiles: %+v", profiles)
|
|
}
|
|
|
|
func TestHandleCreateProfile_BlocksNonPrimaryNonAdmin(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 5},
|
|
}
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":"Extra"}`,
|
|
"user",
|
|
"profile-2",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_RequiresVerifiedPrimaryPINWhenPrimaryHasPIN(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
|
|
PIN: ptr("1234"),
|
|
}); err != nil {
|
|
t.Fatalf("set primary pin: %v", err)
|
|
}
|
|
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 5, AccessPolicyRevision: 7},
|
|
}
|
|
handler.ProfileTokens = access.NewProfileTokenService("test-secret", time.Minute)
|
|
|
|
req := newAuthorizedProfileRequestWithSession(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":"Kids"}`,
|
|
"user",
|
|
"profile-1",
|
|
"sess-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "verifying the primary profile PIN") {
|
|
t.Fatalf("body = %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_AllowsVerifiedPrimaryPIN(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
|
|
PIN: ptr("1234"),
|
|
}); err != nil {
|
|
t.Fatalf("set primary pin: %v", err)
|
|
}
|
|
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 5, AccessPolicyRevision: 7},
|
|
}
|
|
handler.ProfileTokens = access.NewProfileTokenService("test-secret", time.Minute)
|
|
|
|
token, _, err := handler.ProfileTokens.Mint(access.ProfileTokenClaims{
|
|
UserID: 1,
|
|
SessionID: "sess-1",
|
|
ProfileID: "profile-1",
|
|
PolicyRevision: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("mint profile token: %v", err)
|
|
}
|
|
|
|
req := newAuthorizedProfileRequestWithSession(
|
|
http.MethodPost,
|
|
"/profiles",
|
|
`{"name":"Kids"}`,
|
|
"user",
|
|
"profile-1",
|
|
"sess-1",
|
|
)
|
|
req.Header.Set("X-Profile-Token", token)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_BlocksAdminOnlyFieldsForNonAdmin(t *testing.T) {
|
|
store := newEmptyProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.UserRepo = testProfileUserRepo{
|
|
user: &models.User{ID: 1, MaxProfiles: 5},
|
|
}
|
|
|
|
req := newAuthorizedProfileRequest(`{"name":"Kids","is_child":true}`)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 0 {
|
|
t.Fatalf("profile count = %d, want 0", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateProfile_AllowsFirstProfileBootstrapForNonAdmin(t *testing.T) {
|
|
store := newEmptyProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequest(`{"name":"Main"}`)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleCreateProfile(rr, req)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profiles, err := store.ListProfiles(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("list profiles: %v", err)
|
|
}
|
|
if len(profiles) != 1 {
|
|
t.Fatalf("profile count = %d, want 1", len(profiles))
|
|
}
|
|
}
|
|
|
|
func ptr[T any](value T) *T {
|
|
return &value
|
|
}
|
|
|
|
func TestHandleUpdateProfile_AllowsPrimaryToSetAccessFields(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-2",
|
|
`{"max_content_rating":"PG-13"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2"))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-2")
|
|
if err != nil || profile == nil {
|
|
t.Fatalf("get profile: %v, %v", profile, err)
|
|
}
|
|
if profile.MaxContentRating != "PG-13" {
|
|
t.Fatalf("max_content_rating = %q, want PG-13", profile.MaxContentRating)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_BlocksAccessFieldsForNonPrimary(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-2",
|
|
`{"max_content_rating":"PG-13"}`,
|
|
"user",
|
|
"profile-2",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2"))
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-2")
|
|
if err != nil || profile == nil {
|
|
t.Fatalf("get profile: %v, %v", profile, err)
|
|
}
|
|
if profile.MaxContentRating != "" {
|
|
t.Fatalf("max_content_rating = %q, want empty", profile.MaxContentRating)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_AllowsSelfServiceFieldsForNonAdmin(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-1",
|
|
`{"subtitle_mode":"always","pin":"1234"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-1"))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-1")
|
|
if err != nil {
|
|
t.Fatalf("get profile: %v", err)
|
|
}
|
|
if profile == nil {
|
|
t.Fatal("profile missing")
|
|
}
|
|
if profile.SubtitleMode != "always" {
|
|
t.Fatalf("subtitle_mode = %q, want %q", profile.SubtitleMode, "always")
|
|
}
|
|
if profile.PINHash == "" {
|
|
t.Fatal("expected pin hash to be set")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_AllowsNonAdminToUpdateAnyOwnedProfile(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-2",
|
|
`{"subtitle_mode":"always"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2"))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_RejectsDuplicateNameOnRename(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-2",
|
|
`{"name":"MAIN"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-2"))
|
|
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp errorResponse
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Error != "name_conflict" {
|
|
t.Fatalf("error = %q, want %q", resp.Error, "name_conflict")
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-2")
|
|
if err != nil {
|
|
t.Fatalf("get profile: %v", err)
|
|
}
|
|
if profile == nil || profile.Name != "Kids" {
|
|
t.Fatalf("profile name changed despite conflict: %+v", profile)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_AllowsKeepingOwnNameOnUpdate(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
// Saving a profile without renaming resubmits its own name; the
|
|
// conflict check must exclude the profile being updated.
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-1",
|
|
`{"name":"Main","subtitle_mode":"always"}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-1"))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_RejectsWhitespaceOnlyName(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-1",
|
|
`{"name":" "}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-1"))
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-1")
|
|
if err != nil {
|
|
t.Fatalf("get profile: %v", err)
|
|
}
|
|
if profile == nil || profile.Name != "Main" {
|
|
t.Fatalf("profile name changed despite rejection: %+v", profile)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpdateProfile_TrimsStoredNameOnRename(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodPut,
|
|
"/profiles/profile-1",
|
|
`{"name":" Laura "}`,
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleUpdateProfile(rr, withProfileRouteParam(req, "id", "profile-1"))
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-1")
|
|
if err != nil {
|
|
t.Fatalf("get profile: %v", err)
|
|
}
|
|
if profile == nil || profile.Name != "Laura" {
|
|
t.Fatalf("stored name not trimmed: %+v", profile)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteProfile_AllowsPrimaryToDeleteOther(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodDelete,
|
|
"/profiles/profile-2",
|
|
"",
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-2"))
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-2")
|
|
if err == nil && profile != nil {
|
|
t.Fatal("expected profile to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteProfile_BlocksPrimaryDeletion(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodDelete,
|
|
"/profiles/profile-1",
|
|
"",
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-1"))
|
|
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
profile, err := store.GetProfile(context.Background(), "profile-1")
|
|
if err != nil || profile == nil {
|
|
t.Fatalf("expected primary profile to be preserved: %v", err)
|
|
}
|
|
}
|
|
|
|
type stubPlaybackSessionsLoader struct {
|
|
sessions []playbackSessionRow
|
|
userID int
|
|
err error
|
|
}
|
|
|
|
func (s *stubPlaybackSessionsLoader) Load(
|
|
_ context.Context,
|
|
_ *http.Request,
|
|
query PlaybackSessionsQuery,
|
|
) ([]playbackSessionRow, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if query.UserID != s.userID {
|
|
return []playbackSessionRow{}, nil
|
|
}
|
|
return s.sessions, nil
|
|
}
|
|
|
|
func TestHandleListHouseholdSessions_ForbiddenForNonPrimary(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil {
|
|
t.Fatalf("create profile: %v", err)
|
|
}
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.SessionsReader = &stubPlaybackSessionsLoader{userID: 1}
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodGet,
|
|
"/profiles/household/sessions",
|
|
"",
|
|
"user",
|
|
"profile-2",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleListHouseholdSessions(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleListHouseholdSessions_ReturnsAccountSessionsForPrimary(t *testing.T) {
|
|
store := newProfileTestStore(t)
|
|
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
|
handler.SessionsReader = &stubPlaybackSessionsLoader{
|
|
userID: 1,
|
|
sessions: []playbackSessionRow{
|
|
{
|
|
SessionID: "sess-1",
|
|
UserID: 1,
|
|
ProfileID: "profile-2",
|
|
ProfileName: "Kids",
|
|
MediaTitle: "Example Movie",
|
|
PlayMethod: "direct",
|
|
},
|
|
},
|
|
}
|
|
|
|
req := newAuthorizedProfileRequestWithRole(
|
|
http.MethodGet,
|
|
"/profiles/household/sessions",
|
|
"",
|
|
"user",
|
|
"profile-1",
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleListHouseholdSessions(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp []playbackSessionRow
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(resp) != 1 || resp[0].SessionID != "sess-1" || resp[0].ProfileName != "Kids" {
|
|
t.Fatalf("sessions = %#v", resp)
|
|
}
|
|
}
|