diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index f6743b975a..0dc0629eac 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -45,8 +45,12 @@ [:email-comments [::sm/one-of #{:all :partial :none}]] [:email-invites [::sm/one-of #{:all :none}]]]) +(def system-managed-props + "Props keys managed by the system (not user-writable via RPC)." + #{:subscription}) + (def schema:props - [:map {:title "ProfileProps"} + [:map {:title "ProfileProps" :closed true} [:plugins {:optional true} schema:plugin-registry] [:renderer {:optional true} [::sm/one-of #{:svg :wasm}]] [:mcp-enabled {:optional true} ::sm/boolean] @@ -454,7 +458,7 @@ (assoc props k v)) props)) (:props profile) - props)] + (apply dissoc props system-managed-props))] (db/update! conn :profile {:props (db/tjson props)} diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index b4fb080583..5c5ef63c0d 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -1160,3 +1160,45 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation)) (t/is (th/ex-of-code? error :email-as-password)))) + + +(t/deftest update-profile-props-rejects-subscription + ;; N1-16: Mass Assignment — :subscription must not be writable via RPC + ;; The closed schema rejects :subscription at validation time + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:subscription {:type "unlimited" :status "active"}}} + out (th/command! data)] + + ;; The call must fail with validation error + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (t/is (th/ex-of-code? (:error out) :params-validation)) + + ;; And :subscription must NOT be persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (nil? (get-in props [:props :subscription])) + ":subscription must not be writable via update-profile-props")))) + + +(t/deftest update-profile-props-accepts-valid-keys + ;; Verify that valid props keys still work after closing the schema + (let [profile (th/create-profile* 1) + data {::th/type :update-profile-props + ::rpc/profile-id (:id profile) + :props {:onboarding-viewed true + :newsletter-updates false + :renderer :wasm}} + out (th/command! data)] + + ;; The call should succeed + (t/is (nil? (:error out))) + + ;; And all valid keys should be persisted + (let [saved (th/db-get :profile {:id (:id profile)}) + props (profile/decode-row saved)] + (t/is (true? (get-in props [:props :onboarding-viewed]))) + (t/is (false? (get-in props [:props :newsletter-updates]))) + (t/is (= :wasm (get-in props [:props :renderer]))))))