Skip to content

Commit bd32f21

Browse files
External resource (#31)
* new external resource db model * returning external resources * validating active sub * restricted signed url * handling upload workflow of external resource * rebase fixes * redis cache try * put and lite subscription * host id * improvements and test fixes * delete external resource * address feedback * Address feedback * fix ci * Address feedback * address feedback --------- Co-authored-by: Gianni Carlo <gcarlo89@hotmail.com>
1 parent fa47c07 commit bd32f21

32 files changed

Lines changed: 2023 additions & 178 deletions

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ jobs:
4949
REVENUECAT_API_V2: https://ci.dummy/v2
5050
REVENUECAT_API_V2_KEY: ci-dummy
5151
REVENUECAT_PROJECT_ID: ci-dummy
52+
REVENUECAT_ENTITLEMENT_PRO: ci-dummy-pro
53+
REVENUECAT_ENTITLEMENT_PLUS: ci-dummy-plus
54+
REVENUECAT_ENTITLEMENT_LITE: ci-dummy-lite
5255
PROXY_FILE_URL: https://ci.dummy
5356
APP_VERSION: latest
5457

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ development.env
2222
store/*
2323
.development.env
2424
dump.rdb
25+
tsconfig.tsbuildinfo
2526

2627
# Apple root certificates (should be downloaded fresh from Apple)
2728
certs/*.cer

development.env.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ REVENUECAT_KEY=
3232
REVENUECAT_API_V2=https://api.revenuecat.com/v2
3333
REVENUECAT_API_V2_KEY=
3434
REVENUECAT_PROJECT_ID=
35+
# RC internal entitlement object ids (e.g. entla0aca3f4af) -> tier. Find them at
36+
# RC dashboard > Project > Entitlements (the "id", not the lookup key).
37+
REVENUECAT_ENTITLEMENT_PRO=
38+
REVENUECAT_ENTITLEMENT_PLUS=
39+
REVENUECAT_ENTITLEMENT_LITE=
3540

3641
REDIS_ENV=bookplayer_
3742
REDIS_URL=redis://127.0.0.1:6379

docker/ecs/task-definition.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@
116116
"name": "REVENUECAT_PROJECT_ID",
117117
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:REVENUECAT_PROJECT_ID::"
118118
},
119+
{
120+
"name": "REVENUECAT_ENTITLEMENT_PRO",
121+
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:REVENUECAT_ENTITLEMENT_PRO::"
122+
},
123+
{
124+
"name": "REVENUECAT_ENTITLEMENT_PLUS",
125+
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:REVENUECAT_ENTITLEMENT_PLUS::"
126+
},
127+
{
128+
"name": "REVENUECAT_ENTITLEMENT_LITE",
129+
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:REVENUECAT_ENTITLEMENT_LITE::"
130+
},
119131
{
120132
"name": "PROXY_FILE_URL",
121133
"valueFrom": "arn:aws:secretsmanager:us-east-1:327317202676:secret:prod/bookplayer-api:PROXY_FILE_URL::"

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"semver": "^7.7.2",
5252
"ts-node": "^10.5.0",
5353
"verify-apple-id-token": "^3.1.2",
54-
"winston": "^3.9.0"
54+
"winston": "^3.9.0",
55+
"zod": "^3"
5556
},
5657
"devDependencies": {
5758
"@types/compression": "^1.7.2",

src/__tests__/middlewares/subscription.test.ts

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
22

33
const mockIsActive =
4-
jest.fn<(externalId: string) => Promise<boolean>>();
4+
jest.fn<(externalId: string) => Promise<SubscriptionState>>();
5+
const mockFetchLiveEntitlements =
6+
jest.fn<(externalId: string) => Promise<SubscriptionState | null>>();
57
const mockGetExternalIdByUserId =
68
jest.fn<(userId: number) => Promise<string | null>>();
79

8-
// Replace the SubscriptionService class with one whose isActive we control.
10+
// Replace the SubscriptionService class with one whose methods we control.
911
// Hoisted by jest before the module-under-test is imported, so the singleton
1012
// in subscription.ts ends up holding our mock.
1113
jest.mock('../../services/SubscriptionService', () => ({
1214
SubscriptionService: jest.fn().mockImplementation(() => ({
1315
isActive: mockIsActive,
16+
fetchLiveEntitlements: mockFetchLiveEntitlements,
1417
})),
1518
}));
1619

@@ -21,7 +24,8 @@ jest.mock('../../services/db/UserDB', () => ({
2124
}));
2225

2326
// eslint-disable-next-line import/first
24-
import { checkSubscription } from '../../api/middlewares/subscription';
27+
import { checkSubscription, requireSubscription } from '../../api/middlewares/subscription';
28+
import { SubscriptionState, SubscriptionTierEnum } from '../../types/user';
2529

2630
describe('checkSubscription middleware', () => {
2731
let req: any;
@@ -49,7 +53,7 @@ describe('checkSubscription middleware', () => {
4953
});
5054

5155
it('calls next() when isActive returns true', async () => {
52-
mockIsActive.mockResolvedValue(true);
56+
mockIsActive.mockResolvedValue({ active: true, verified: 'local', subscriptions: [] });
5357
await checkSubscription(req, res, next);
5458
expect(mockIsActive).toHaveBeenCalledWith('ext-1');
5559
expect(next).toHaveBeenCalledTimes(1);
@@ -58,7 +62,7 @@ describe('checkSubscription middleware', () => {
5862
});
5963

6064
it('returns 400 "not subscribed" when isActive returns false', async () => {
61-
mockIsActive.mockResolvedValue(false);
65+
mockIsActive.mockResolvedValue({ active: false, verified: 'local', subscriptions: [] });
6266
await checkSubscription(req, res, next);
6367
expect(mockIsActive).toHaveBeenCalledWith('ext-1');
6468
expect(res.status).toHaveBeenCalledWith(400);
@@ -77,7 +81,7 @@ describe('checkSubscription middleware', () => {
7781
it('falls back to DB lookup when JWT lacks external_id (legacy Apple login)', async () => {
7882
req.user = { id_user: 42 }; // no external_id — pre-fix Apple JWT shape
7983
mockGetExternalIdByUserId.mockResolvedValue('ext-from-db');
80-
mockIsActive.mockResolvedValue(true);
84+
mockIsActive.mockResolvedValue({ active: true, verified: 'local', subscriptions: [] });
8185

8286
await checkSubscription(req, res, next);
8387

@@ -86,3 +90,109 @@ describe('checkSubscription middleware', () => {
8690
expect(next).toHaveBeenCalledTimes(1);
8791
});
8892
});
93+
94+
describe('requireSubscription middleware', () => {
95+
let res: any;
96+
let next: jest.Mock;
97+
98+
beforeEach(() => {
99+
res = {
100+
status: jest.fn().mockReturnThis(),
101+
json: jest.fn().mockReturnThis(),
102+
};
103+
next = jest.fn();
104+
mockFetchLiveEntitlements.mockReset();
105+
mockGetExternalIdByUserId.mockReset();
106+
// Default: live RC check finds no upgrade.
107+
mockFetchLiveEntitlements.mockResolvedValue(null);
108+
mockGetExternalIdByUserId.mockResolvedValue('ext-1');
109+
});
110+
111+
it('calls next() when the user holds an allowed tier (no RC call)', async () => {
112+
const req: any = { user: { id_user: 1, external_id: 'ext-1', subscriptions: [SubscriptionTierEnum.PRO] } };
113+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
114+
expect(next).toHaveBeenCalledTimes(1);
115+
expect(res.status).not.toHaveBeenCalled();
116+
expect(mockFetchLiveEntitlements).not.toHaveBeenCalled();
117+
});
118+
119+
it('matches a tier anywhere in the subscriptions array (not just index 0)', async () => {
120+
const req: any = {
121+
user: { id_user: 1, external_id: 'ext-1', subscriptions: [SubscriptionTierEnum.LITE, SubscriptionTierEnum.PRO] },
122+
};
123+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
124+
expect(next).toHaveBeenCalledTimes(1);
125+
expect(mockFetchLiveEntitlements).not.toHaveBeenCalled();
126+
});
127+
128+
it('falls back to RC and allows the action when RC confirms the upgraded tier (lite → pro)', async () => {
129+
const req: any = { user: { id_user: 1, external_id: 'ext-1', subscriptions: [SubscriptionTierEnum.LITE] } };
130+
mockFetchLiveEntitlements.mockResolvedValue({
131+
active: true,
132+
verified: 'rc',
133+
subscriptions: ['pro'],
134+
});
135+
136+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
137+
138+
expect(mockFetchLiveEntitlements).toHaveBeenCalledWith('ext-1');
139+
expect(next).toHaveBeenCalledTimes(1);
140+
expect(res.status).not.toHaveBeenCalled();
141+
// req.user.subscriptions refreshed for downstream handlers
142+
expect(req.user.subscriptions).toEqual(['pro']);
143+
});
144+
145+
it('returns 403 when RC also lacks the required tier (no real upgrade)', async () => {
146+
const req: any = { user: { id_user: 1, external_id: 'ext-1', subscriptions: [SubscriptionTierEnum.LITE] } };
147+
mockFetchLiveEntitlements.mockResolvedValue({
148+
active: true,
149+
verified: 'rc',
150+
subscriptions: ['lite'],
151+
});
152+
153+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
154+
155+
expect(mockFetchLiveEntitlements).toHaveBeenCalledWith('ext-1');
156+
expect(res.status).toHaveBeenCalledWith(403);
157+
expect(res.json).toHaveBeenCalledWith({ message: 'Requires one of: pro' });
158+
expect(next).not.toHaveBeenCalled();
159+
});
160+
161+
it('returns 403 when RC is unreachable (fetchLiveEntitlements returns null)', async () => {
162+
const req: any = { user: { id_user: 1, external_id: 'ext-1', subscriptions: [SubscriptionTierEnum.FREE] } };
163+
mockFetchLiveEntitlements.mockResolvedValue(null);
164+
165+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
166+
167+
expect(res.status).toHaveBeenCalledWith(403);
168+
expect(next).not.toHaveBeenCalled();
169+
});
170+
171+
it('resolves externalId via DB lookup when the JWT lacks external_id, then checks RC', async () => {
172+
const req: any = { user: { id_user: 42, subscriptions: [SubscriptionTierEnum.LITE] } };
173+
mockGetExternalIdByUserId.mockResolvedValue('ext-from-db');
174+
mockFetchLiveEntitlements.mockResolvedValue({ active: true, verified: 'rc', subscriptions: ['pro'] });
175+
176+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
177+
178+
expect(mockGetExternalIdByUserId).toHaveBeenCalledWith(42);
179+
expect(mockFetchLiveEntitlements).toHaveBeenCalledWith('ext-from-db');
180+
expect(next).toHaveBeenCalledTimes(1);
181+
});
182+
183+
it('returns 403 (does not throw) when subscriptions is undefined and RC finds nothing', async () => {
184+
const req: any = { user: { id_user: 1, external_id: 'ext-1' } };
185+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
186+
expect(res.status).toHaveBeenCalledWith(403);
187+
expect(next).not.toHaveBeenCalled();
188+
});
189+
190+
it('returns 400 with a message when req.user is missing', async () => {
191+
const req: any = {};
192+
await requireSubscription([SubscriptionTierEnum.PRO])(req, res, next);
193+
expect(res.status).toHaveBeenCalledWith(400);
194+
expect(res.json).toHaveBeenCalledWith({ message: 'User data missing.' });
195+
expect(next).not.toHaveBeenCalled();
196+
expect(mockFetchLiveEntitlements).not.toHaveBeenCalled();
197+
});
198+
});

0 commit comments

Comments
 (0)