From 2701e52b5a6e8dabfad7426b2e5bad26df235059 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:37:12 -0700 Subject: [PATCH 1/6] fix(api): scope user-subscriber reads to entity_type 'User' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscriptions.user_id is overloaded: for Event rows it mirrors the event id, and event ids are allocated independently of user ids. The subscribers endpoint and the upload-notification fan-outs in handle_track/handle_playlist matched on user_id alone, so a follower of event N counts as a subscriber of user N whenever the ids collide — they appear in /v1/users/N/subscribers and get notified about uploads from an artist they never subscribed to. This is reachable today with a single Event row, and OpenAudio/go-openaudio#469 (which legalizes cross-type coexistence) widens the exposure. The trigger functions are updated in ddl/functions/ (pg_migrate.sh re-applies them on md5 change) with the schema dump edited to match. Co-Authored-By: Claude Fable 5 --- api/v1_users_subscribers.go | 6 +++++- api/v1_users_subscribers_test.go | 16 +++++++++++----- ddl/functions/handle_playlist.sql | 4 ++++ ddl/functions/handle_track.sql | 4 ++++ sql/01_schema.sql | 8 ++++++++ 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/api/v1_users_subscribers.go b/api/v1_users_subscribers.go index 27116c82..65a608d1 100644 --- a/api/v1_users_subscribers.go +++ b/api/v1_users_subscribers.go @@ -20,7 +20,11 @@ func (app *ApiServer) v1UsersSubscribers(c *fiber.Ctx) error { FROM subscriptions WHERE - user_id = @userId + -- user_id is overloaded: it mirrors the event id for Event + -- subscriptions, so an unqualified match would count followers of + -- an event whose id collides with this user's id. + entity_type = 'User' + AND user_id = @userId AND is_current = true AND is_delete = false ORDER BY diff --git a/api/v1_users_subscribers_test.go b/api/v1_users_subscribers_test.go index 3c8bf897..0439c2fc 100644 --- a/api/v1_users_subscribers_test.go +++ b/api/v1_users_subscribers_test.go @@ -18,6 +18,7 @@ func TestUsersSubscribers(t *testing.T) { {"user_id": 3, "handle": "subscriber3", "name": "Subscriber 3"}, {"user_id": 4, "handle": "deletedsub", "name": "Deleted Sub"}, {"user_id": 5, "handle": "oldsub", "name": "Old Sub"}, + {"user_id": 6, "handle": "eventonlyfan", "name": "Event Only Fan"}, }, "aggregate_user": []map[string]any{ {"user_id": 1, "track_count": 1}, @@ -25,17 +26,22 @@ func TestUsersSubscribers(t *testing.T) { {"user_id": 3, "track_count": 1}, {"user_id": 4, "track_count": 1}, {"user_id": 5, "track_count": 1}, + {"user_id": 6, "track_count": 1}, }, } database.Seed(app.pool.Replicas[0], fixtures) + // The last row is an Event subscription whose event id collides with the + // artist's user id (user_id mirrors the event id for Event rows). Its + // subscriber follows event 1, not user 1, and must NOT be listed. _, err := app.pool.Exec(t.Context(), ` - INSERT INTO subscriptions (user_id, subscriber_id, is_current, is_delete, txhash) + INSERT INTO subscriptions (user_id, subscriber_id, is_current, is_delete, txhash, entity_type, entity_id) VALUES - (1, 3, TRUE, FALSE, 'tx-sub-3'), - (1, 2, TRUE, FALSE, 'tx-sub-2'), - (1, 4, TRUE, TRUE, 'tx-sub-deleted'), - (1, 5, FALSE, FALSE, 'tx-sub-not-current') + (1, 3, TRUE, FALSE, 'tx-sub-3', 'User', NULL), + (1, 2, TRUE, FALSE, 'tx-sub-2', 'User', NULL), + (1, 4, TRUE, TRUE, 'tx-sub-deleted', 'User', NULL), + (1, 5, FALSE, FALSE, 'tx-sub-not-current', 'User', NULL), + (1, 6, TRUE, FALSE, 'tx-sub-event-collision', 'Event', 1) `) assert.NoError(t, err) diff --git a/ddl/functions/handle_playlist.sql b/ddl/functions/handle_playlist.sql index 6c844935..d582dd8b 100644 --- a/ddl/functions/handle_playlist.sql +++ b/ddl/functions/handle_playlist.sql @@ -60,6 +60,10 @@ begin from subscriptions where is_current and not is_delete and + -- user_id is overloaded: it mirrors the event id for Event + -- subscriptions, so an unqualified match can pick up followers of an + -- event whose id collides with this artist's user id. + entity_type = 'User' and user_id=new.playlist_owner_id ) into subscriber_user_ids; if array_length(subscriber_user_ids, 1) > 0 then diff --git a/ddl/functions/handle_track.sql b/ddl/functions/handle_track.sql index 32b54790..e51783a2 100644 --- a/ddl/functions/handle_track.sql +++ b/ddl/functions/handle_track.sql @@ -52,6 +52,10 @@ begin from subscriptions where is_current and not is_delete and + -- user_id is overloaded: it mirrors the event id for Event + -- subscriptions, so an unqualified match can pick up followers of an + -- event whose id collides with this artist's user id. + entity_type = 'User' and user_id=new.owner_id ) into subscriber_user_ids; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 8ec65688..9e791fb5 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -3537,6 +3537,10 @@ begin from subscriptions where is_current and not is_delete and + -- user_id is overloaded: it mirrors the event id for Event + -- subscriptions, so an unqualified match can pick up followers of an + -- event whose id collides with this artist's user id. + entity_type = 'User' and user_id=new.playlist_owner_id ) into subscriber_user_ids; if array_length(subscriber_user_ids, 1) > 0 then @@ -4784,6 +4788,10 @@ begin from subscriptions where is_current and not is_delete and + -- user_id is overloaded: it mirrors the event id for Event + -- subscriptions, so an unqualified match can pick up followers of an + -- event whose id collides with this artist's user id. + entity_type = 'User' and user_id=new.owner_id ) into subscriber_user_ids; From 7ae7175821e86631ee09fd41d9bdd526970bcd9f Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:37:13 -0700 Subject: [PATCH 2/6] test(api): de-collide the events-followers subscription fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy User-type row shared (subscriber_id, user_id) with the deleted-event row. Production's subscriptions_current_uniq_idx — seeded into the test schema by #1011 — keys current rows on exactly that pair, so seeding panics with a unique violation and the test fails before exercising the endpoint. Move the legacy row to its own subscriber; both exclusion behaviors (entity_type filter, is_delete filter) stay covered. Once OpenAudio/go-openaudio#469 widens the index to include entity_type, the same-subscriber collision becomes legal again and is worth re-adding. Co-Authored-By: Claude Fable 5 --- api/v1_events_followers_test.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/api/v1_events_followers_test.go b/api/v1_events_followers_test.go index 6f5e937f..d5aa5926 100644 --- a/api/v1_events_followers_test.go +++ b/api/v1_events_followers_test.go @@ -514,7 +514,12 @@ func TestEventsFollowers_ReturnsOnlyLiveEventSubscribers(t *testing.T) { app := emptyTestApp(t) database.Seed(app.pool.Replicas[0], database.FixtureMap{ - "users": testEventFollowersBaseUsers(), + "users": append(testEventFollowersBaseUsers(), map[string]any{ + "user_id": 4, + "handle": "legacyfan", + "handle_lc": "legacyfan", + "name": "Legacy Fan", + }), "tracks": { { "track_id": 1, @@ -560,8 +565,15 @@ func TestEventsFollowers_ReturnsOnlyLiveEventSubscribers(t *testing.T) { }, // Legacy user-type subscription with a colliding numeric id — // must NOT show up. + // + // Seeded from subscriber 4 (not 1) so it doesn't share + // (subscriber_id, user_id) with the deleted-event row below: + // production's subscriptions_current_uniq_idx currently keys + // current rows on that pair alone. Once go-openaudio#469 widens + // the index to include entity_type, same-subscriber coexistence + // becomes legal and is worth covering here again. { - "subscriber_id": 1, + "subscriber_id": 4, "user_id": 200, "entity_type": "User", "entity_id": nil, From fe3ced9c55e3432e97a27de5e47731cd6a7afe3a Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:37:13 -0700 Subject: [PATCH 3/6] build(ci): run tests with -count=1 to defeat the test-result cache setup-go restores the go-build cache (which includes test results) keyed on go.sum, but the test schema lives in a dockerized Postgres that Go's cache invalidation cannot see. A sql/-only change therefore rides a stale green: #1011 merged with a test that fails against its own schema because the api package's results were served from cache. The first PR to touch a Go file afterwards inherited the failure. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 167b5471..097c5439 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,10 @@ jobs: done - name: Run tests - run: go test -cover ./... + # -count=1 defeats Go's test-result cache. The test schema lives in a + # dockerized Postgres the cache can't see, so a sql/-only change can + # otherwise ride a stale green (how #1011 merged with a failing test). + run: go test -count=1 -cover ./... - name: Shutdown From 5e364f5127f0393c40ff13c375295c1aa4e25f7a Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:46:56 -0700 Subject: [PATCH 4/6] test(api): de-collide the follow_state fixture too TestEventFollowState_CountsOnlyLiveEventSubscriptions seeds the same (subscriber_id, user_id) pair for an Event row and a legacy User row, tripping the same unique-index panic the previous commit fixed in the followers test. Move the User row to its own subscriber; swept every other subscriptions fixture in the repo for current-row pair collisions and this was the last one. Co-Authored-By: Claude Fable 5 --- api/v1_events_followers_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/v1_events_followers_test.go b/api/v1_events_followers_test.go index d5aa5926..5956004e 100644 --- a/api/v1_events_followers_test.go +++ b/api/v1_events_followers_test.go @@ -172,8 +172,15 @@ func TestEventFollowState_CountsOnlyLiveEventSubscriptions(t *testing.T) { }, // A legacy user-type subscription with matching numeric id — // must NOT be counted. + // + // Seeded from subscriber 5 so it doesn't share + // (subscriber_id, user_id) with subscriber 2's Event row above: + // production's subscriptions_current_uniq_idx currently keys + // current rows on that pair alone. Once go-openaudio#469 widens + // the index to include entity_type, same-subscriber coexistence + // becomes legal and is worth covering here again. { - "subscriber_id": 2, + "subscriber_id": 5, "user_id": 200, "entity_type": "User", "entity_id": nil, From 53ccdb0ce745030a6e92b11df238e14627b35338 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:05:23 -0700 Subject: [PATCH 5/6] style: drop explanatory comments from the entity_type filter changes Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 3 --- api/v1_events_followers_test.go | 14 -------------- api/v1_users_subscribers.go | 3 --- api/v1_users_subscribers_test.go | 3 --- ddl/functions/handle_playlist.sql | 3 --- ddl/functions/handle_track.sql | 3 --- sql/01_schema.sql | 6 ------ 7 files changed, 35 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 097c5439..06398eeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,9 +44,6 @@ jobs: done - name: Run tests - # -count=1 defeats Go's test-result cache. The test schema lives in a - # dockerized Postgres the cache can't see, so a sql/-only change can - # otherwise ride a stale green (how #1011 merged with a failing test). run: go test -count=1 -cover ./... diff --git a/api/v1_events_followers_test.go b/api/v1_events_followers_test.go index 5956004e..6d236b58 100644 --- a/api/v1_events_followers_test.go +++ b/api/v1_events_followers_test.go @@ -172,13 +172,6 @@ func TestEventFollowState_CountsOnlyLiveEventSubscriptions(t *testing.T) { }, // A legacy user-type subscription with matching numeric id — // must NOT be counted. - // - // Seeded from subscriber 5 so it doesn't share - // (subscriber_id, user_id) with subscriber 2's Event row above: - // production's subscriptions_current_uniq_idx currently keys - // current rows on that pair alone. Once go-openaudio#469 widens - // the index to include entity_type, same-subscriber coexistence - // becomes legal and is worth covering here again. { "subscriber_id": 5, "user_id": 200, @@ -572,13 +565,6 @@ func TestEventsFollowers_ReturnsOnlyLiveEventSubscribers(t *testing.T) { }, // Legacy user-type subscription with a colliding numeric id — // must NOT show up. - // - // Seeded from subscriber 4 (not 1) so it doesn't share - // (subscriber_id, user_id) with the deleted-event row below: - // production's subscriptions_current_uniq_idx currently keys - // current rows on that pair alone. Once go-openaudio#469 widens - // the index to include entity_type, same-subscriber coexistence - // becomes legal and is worth covering here again. { "subscriber_id": 4, "user_id": 200, diff --git a/api/v1_users_subscribers.go b/api/v1_users_subscribers.go index 65a608d1..78c53e4a 100644 --- a/api/v1_users_subscribers.go +++ b/api/v1_users_subscribers.go @@ -20,9 +20,6 @@ func (app *ApiServer) v1UsersSubscribers(c *fiber.Ctx) error { FROM subscriptions WHERE - -- user_id is overloaded: it mirrors the event id for Event - -- subscriptions, so an unqualified match would count followers of - -- an event whose id collides with this user's id. entity_type = 'User' AND user_id = @userId AND is_current = true diff --git a/api/v1_users_subscribers_test.go b/api/v1_users_subscribers_test.go index 0439c2fc..98e3a3b7 100644 --- a/api/v1_users_subscribers_test.go +++ b/api/v1_users_subscribers_test.go @@ -31,9 +31,6 @@ func TestUsersSubscribers(t *testing.T) { } database.Seed(app.pool.Replicas[0], fixtures) - // The last row is an Event subscription whose event id collides with the - // artist's user id (user_id mirrors the event id for Event rows). Its - // subscriber follows event 1, not user 1, and must NOT be listed. _, err := app.pool.Exec(t.Context(), ` INSERT INTO subscriptions (user_id, subscriber_id, is_current, is_delete, txhash, entity_type, entity_id) VALUES diff --git a/ddl/functions/handle_playlist.sql b/ddl/functions/handle_playlist.sql index d582dd8b..be452a8e 100644 --- a/ddl/functions/handle_playlist.sql +++ b/ddl/functions/handle_playlist.sql @@ -60,9 +60,6 @@ begin from subscriptions where is_current and not is_delete and - -- user_id is overloaded: it mirrors the event id for Event - -- subscriptions, so an unqualified match can pick up followers of an - -- event whose id collides with this artist's user id. entity_type = 'User' and user_id=new.playlist_owner_id ) into subscriber_user_ids; diff --git a/ddl/functions/handle_track.sql b/ddl/functions/handle_track.sql index e51783a2..0d28d855 100644 --- a/ddl/functions/handle_track.sql +++ b/ddl/functions/handle_track.sql @@ -52,9 +52,6 @@ begin from subscriptions where is_current and not is_delete and - -- user_id is overloaded: it mirrors the event id for Event - -- subscriptions, so an unqualified match can pick up followers of an - -- event whose id collides with this artist's user id. entity_type = 'User' and user_id=new.owner_id ) into subscriber_user_ids; diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 9e791fb5..93751524 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -3537,9 +3537,6 @@ begin from subscriptions where is_current and not is_delete and - -- user_id is overloaded: it mirrors the event id for Event - -- subscriptions, so an unqualified match can pick up followers of an - -- event whose id collides with this artist's user id. entity_type = 'User' and user_id=new.playlist_owner_id ) into subscriber_user_ids; @@ -4788,9 +4785,6 @@ begin from subscriptions where is_current and not is_delete and - -- user_id is overloaded: it mirrors the event id for Event - -- subscriptions, so an unqualified match can pick up followers of an - -- event whose id collides with this artist's user id. entity_type = 'User' and user_id=new.owner_id ) into subscriber_user_ids; From 0cd6f4d81dc327113975243dee8bfc7b40e9026d Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:20:59 -0700 Subject: [PATCH 6/6] fix(api): scope does_current_user_subscribe to entity_type 'User' Same event-id collision as the subscriber readers: the current_user_subscribed_targets CTE in get_users.sql matched on subscriptions.user_id alone, so a viewer following event N showed as subscribed to user N. Test seeds a colliding Event subscription and fails without the filter. Co-Authored-By: Claude Fable 5 --- api/dbv1/get_users.sql.go | 1 + api/dbv1/queries/get_users.sql | 1 + api/v1_users_test.go | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/api/dbv1/get_users.sql.go b/api/dbv1/get_users.sql.go index 5824eb07..e3ffa573 100644 --- a/api/dbv1/get_users.sql.go +++ b/api/dbv1/get_users.sql.go @@ -39,6 +39,7 @@ current_user_subscribed_targets AS ( FROM subscriptions s JOIN input_users i ON i.user_id = s.user_id WHERE $1 > 0 + AND s.entity_type = 'User' AND s.subscriber_id = $1 AND s.is_delete = false GROUP BY s.user_id diff --git a/api/dbv1/queries/get_users.sql b/api/dbv1/queries/get_users.sql index 468a76b8..2b9c1389 100644 --- a/api/dbv1/queries/get_users.sql +++ b/api/dbv1/queries/get_users.sql @@ -23,6 +23,7 @@ current_user_subscribed_targets AS ( FROM subscriptions s JOIN input_users i ON i.user_id = s.user_id WHERE @my_id > 0 + AND s.entity_type = 'User' AND s.subscriber_id = @my_id AND s.is_delete = false GROUP BY s.user_id diff --git a/api/v1_users_test.go b/api/v1_users_test.go index 8b3cd320..ec5347b7 100644 --- a/api/v1_users_test.go +++ b/api/v1_users_test.go @@ -5,6 +5,7 @@ import ( "testing" "api.audius.co/api/dbv1" + "api.audius.co/database" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -87,6 +88,51 @@ func TestUserQuery(t *testing.T) { } } +func TestUserQuery_DoesCurrentUserSubscribeIgnoresEventSubscriptions(t *testing.T) { + app := emptyTestApp(t) + + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": []map[string]any{ + {"user_id": 1, "handle": "artist", "name": "Artist"}, + {"user_id": 2, "handle": "eventhost", "name": "Event Host"}, + {"user_id": 3, "handle": "viewer", "name": "Viewer"}, + }, + "subscriptions": []map[string]any{ + { + "subscriber_id": 3, + "user_id": 1, + "entity_type": "User", + "entity_id": nil, + "is_current": true, + "is_delete": false, + "txhash": "tx-user-sub", + }, + { + "subscriber_id": 3, + "user_id": 2, + "entity_type": "Event", + "entity_id": 2, + "is_current": true, + "is_delete": false, + "txhash": "tx-event-collision", + }, + }, + }) + + users, err := app.queries.Users(t.Context(), dbv1.GetUsersParams{ + MyID: 3, + Ids: []int32{1, 2}, + }) + assert.NoError(t, err) + require.Len(t, users, 2) + byID := map[int32]dbv1.User{} + for _, user := range users { + byID[user.UserID] = user + } + assert.True(t, byID[1].DoesCurrentUserSubscribe) + assert.False(t, byID[2].DoesCurrentUserSubscribe) +} + func TestGetUsers(t *testing.T) { app := testAppWithFixtures(t) var userResponse struct {