Skip to content

Commit 4ecc9f9

Browse files
Merge pull request #242 from geturbackend/feature/auth-fixes-and-react-sdk-tests
2 parents f2d2f4b + 135f776 commit 4ecc9f9

59 files changed

Lines changed: 14098 additions & 8291 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ Main apps:
1010
- `apps/web-dashboard`: React/Vite dashboard
1111
- `packages/common`: shared models, validation, middleware, encryption, DB/model utilities
1212

13+
SDKs:
14+
- `sdks/urbackend-sdk`: Core TypeScript/JavaScript SDK (Framework-agnostic)
15+
- `sdks/urbackend-react`: React SDK (`UrProvider`, `useUrContext`, UI components)
16+
- `sdks/urbackend-python`: Official Python SDK (Requests-based)
17+
1318
Workspace scripts are defined in [package.json](/package.json).
1419

1520
## Important project rules
@@ -69,6 +74,7 @@ Behavior:
6974
## Redis key patterns
7075
- Refresh session: `project:auth:refresh:session:{tokenId}`
7176
- OAuth state: `project:auth:oauth:state:{state}` (10min TTL)
77+
- Social Refresh Exchange: `project:social-auth:refresh-exchange:{rtCode}` (Uses atomic `GETDEL` for concurrency safety)
7278
- Mail count: `project:mail:count:{projectId}:{YYYY-MM}` (TTL = end of month)
7379
- Do NOT change these patterns — existing sessions will break
7480

@@ -144,11 +150,26 @@ cd apps/web-dashboard
144150
npm run build
145151
```
146152

153+
Run SDK tests:
154+
```bash
155+
# JS Core SDK
156+
npm run test --workspace=@urbackend/sdk
157+
158+
# React SDK (Vitest)
159+
npm run test --workspace=@urbackend/react
160+
161+
# Python SDK (pytest)
162+
cd sdks/urbackend-python
163+
pytest
164+
```
165+
147166
## Testing expectations
148167

149168
Before shipping auth, RLS, or schema changes:
150169
- run `apps/public-api` tests
151170
- run `apps/dashboard-api` tests
171+
- run `@urbackend/sdk` tests
172+
- run `@urbackend/react` tests
152173
- run `apps/web-dashboard` lint
153174
- run `apps/web-dashboard` build when frontend changed
154175

apps/consumer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "consumer",
3-
"version": "1.0.0",
3+
"version": "0.1.0",
44
"description": "",
55
"main": "src/app.js",
66
"scripts": {

apps/dashboard-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dashboard-api",
3-
"version": "0.10.0",
3+
"version": "0.10.1",
44
"private": true,
55
"license": "AGPL-3.0-only",
66
"main": "src/app.js",

apps/dashboard-api/src/__tests__/billing.controller.test.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
const crypto = require('crypto');
44

5-
class AppError extends Error {
5+
class mockAppError extends Error {
66
constructor(statusCode, message) {
77
super(message);
88
this.statusCode = statusCode;
@@ -22,7 +22,7 @@ jest.mock('@urbackend/common', () => ({
2222
findById: jest.fn(),
2323
sort: jest.fn().mockReturnThis(),
2424
},
25-
AppError,
25+
AppError: mockAppError,
2626
sendProRequestConfirmationEmail: jest.fn().mockResolvedValue(true),
2727
sanitizeNonEmptyString: jest.fn(str => (typeof str === 'string' && str.trim() !== '' ? str.trim() : null)),
2828
sanitizeObjectId: jest.fn(id => id),
@@ -64,7 +64,7 @@ describe('Billing Controller', () => {
6464
describe('createCheckout', () => {
6565
test('returns 403 immediately due to Beta toggle', async () => {
6666
await controller.createCheckout(req, res, next);
67-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
67+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
6868
expect(next.mock.calls[0][0].statusCode).toBe(403);
6969
expect(next.mock.calls[0][0].message).toContain('Automatic payments are disabled');
7070
});
@@ -91,7 +91,7 @@ describe('Billing Controller', () => {
9191

9292
await controller.createProRequest(req, res, next);
9393

94-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
94+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
9595
expect(next.mock.calls[0][0].statusCode).toBe(400);
9696
expect(ProRequest.create).not.toHaveBeenCalled();
9797
});
@@ -102,7 +102,7 @@ describe('Billing Controller', () => {
102102

103103
await controller.createProRequest(req, res, next);
104104

105-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
105+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
106106
expect(next.mock.calls[0][0].statusCode).toBe(400);
107107
expect(ProRequest.create).not.toHaveBeenCalled();
108108
});
@@ -112,7 +112,7 @@ describe('Billing Controller', () => {
112112
test('returns 403 if user is not admin', async () => {
113113
req.user.isAdmin = false;
114114
await controller.getProRequests(req, res, next);
115-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
115+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
116116
expect(next.mock.calls[0][0].statusCode).toBe(403);
117117
});
118118

@@ -133,7 +133,7 @@ describe('Billing Controller', () => {
133133
test('returns 403 if user is not admin', async () => {
134134
req.user.isAdmin = false;
135135
await controller.approveProRequest(req, res, next);
136-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
136+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
137137
expect(next.mock.calls[0][0].statusCode).toBe(403);
138138
});
139139

@@ -166,7 +166,7 @@ describe('Billing Controller', () => {
166166

167167
await controller.approveProRequest(req, res, next);
168168

169-
expect(next).toHaveBeenCalledWith(expect.any(AppError));
169+
expect(next).toHaveBeenCalledWith(expect.any(mockAppError));
170170
expect(next.mock.calls[0][0].statusCode).toBe(400);
171171
expect(next.mock.calls[0][0].message).toContain('already approved');
172172
});

apps/public-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "public-api",
3-
"version": "0.10.0",
3+
"version": "0.10.1",
44
"private": true,
55
"license": "AGPL-3.0-only",
66
"main": "src/app.js",

apps/public-api/src/__tests__/userAuth.refresh.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,36 @@ describe('public userAuth refresh flow', () => {
213213
);
214214
});
215215

216+
test('refresh-token returns 403 when user is soft-deleted', async () => {
217+
const incoming = 'token_2.secret_2';
218+
const session = {
219+
tokenId: 'token_2',
220+
projectId: 'project_1',
221+
userId: 'user_1',
222+
tokenHash: hashToken(incoming),
223+
isUsed: false,
224+
revokedAt: null,
225+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
226+
};
227+
228+
getRefreshSession.mockResolvedValueOnce(session);
229+
Project.__chain.lean.mockResolvedValueOnce(makeProject());
230+
mockModel.findOne.mockReturnValueOnce({
231+
lean: jest.fn().mockResolvedValue({ _id: 'user_1', isDeleted: true, deletedAt: new Date().toISOString() }),
232+
});
233+
234+
const req = makeReq({
235+
headers: { 'x-refresh-token': incoming },
236+
});
237+
const res = makeRes();
238+
239+
await controller.refreshToken(req, res);
240+
241+
expect(res.status).toHaveBeenCalledWith(403);
242+
expect(res.json).toHaveBeenCalledWith({ success: false, data: {}, message: expect.stringContaining('deletion') });
243+
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken', expect.any(Object));
244+
});
245+
216246
test('logout revokes current refresh session when provided', async () => {
217247
const rawToken = 'token_2.secret_2';
218248
const session = {

apps/public-api/src/__tests__/userAuth.social.test.js

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ jest.mock('@urbackend/common', () => {
5151
redis: {
5252
set: jest.fn().mockResolvedValue('OK'),
5353
get: jest.fn(),
54+
getdel: jest.fn(),
5455
del: jest.fn().mockResolvedValue(1),
5556
},
5657
Project: {
@@ -441,7 +442,7 @@ describe('public userAuth social auth', () => {
441442
});
442443

443444
test('exchangeSocialRefreshToken returns refresh token and deletes exchange code', async () => {
444-
redis.get.mockResolvedValueOnce(JSON.stringify({
445+
redis.getdel.mockResolvedValueOnce(JSON.stringify({
445446
token: 'issued_access_token',
446447
refreshToken: 'issued_refresh_token',
447448
}));
@@ -455,8 +456,7 @@ describe('public userAuth social auth', () => {
455456

456457
await controller.exchangeSocialRefreshToken(req, res);
457458

458-
expect(redis.get).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
459-
expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
459+
expect(redis.getdel).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
460460
expect(res.status).toHaveBeenCalledWith(200);
461461
expect(res.json).toHaveBeenCalledWith({
462462
success: true,
@@ -468,7 +468,7 @@ describe('public userAuth social auth', () => {
468468
});
469469

470470
test('exchangeSocialRefreshToken rejects invalid or expired code', async () => {
471-
redis.get.mockResolvedValueOnce(null);
471+
redis.getdel.mockResolvedValueOnce(null);
472472

473473
const req = makeReq();
474474
req.body = {
@@ -482,12 +482,13 @@ describe('public userAuth social auth', () => {
482482
expect(res.status).toHaveBeenCalledWith(400);
483483
expect(res.json).toHaveBeenCalledWith({
484484
success: false,
485+
data: {},
485486
message: 'Invalid or expired refresh token exchange code',
486487
});
487488
});
488489

489490
test('exchangeSocialRefreshToken rejects mismatched token and deletes exchange code', async () => {
490-
redis.get.mockResolvedValueOnce(JSON.stringify({
491+
redis.getdel.mockResolvedValueOnce(JSON.stringify({
491492
token: 'expected_access_token',
492493
refreshToken: 'issued_refresh_token',
493494
}));
@@ -500,11 +501,10 @@ describe('public userAuth social auth', () => {
500501
const res = makeRes();
501502

502503
await controller.exchangeSocialRefreshToken(req, res);
503-
504-
expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_456');
505504
expect(res.status).toHaveBeenCalledWith(403);
506505
expect(res.json).toHaveBeenCalledWith({
507506
success: false,
507+
data: {},
508508
message: 'Invalid refresh token exchange payload',
509509
});
510510
});
@@ -611,4 +611,83 @@ describe('public userAuth social auth', () => {
611611
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
612612
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('not+verified'));
613613
});
614+
615+
test('handleSocialAuthCallback rejects soft-deleted user by provider id', async () => {
616+
redis.get.mockResolvedValueOnce(JSON.stringify({
617+
projectId: 'project_1',
618+
provider: 'github',
619+
callbackUrl: 'http://localhost:5173/auth/callback',
620+
}));
621+
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
622+
623+
// mock soft deleted user
624+
mockUsersModel.findOne.mockResolvedValueOnce({
625+
_id: 'deleted_user',
626+
githubId: '123',
627+
isDeleted: true,
628+
deletedAt: new Date().toISOString()
629+
});
630+
631+
global.fetch
632+
.mockResolvedValueOnce({
633+
ok: true,
634+
json: async () => ({ access_token: 'github_access_token' }),
635+
})
636+
.mockResolvedValueOnce({
637+
ok: true,
638+
json: async () => ({ id: 123, login: 'alice', avatar_url: '' }),
639+
})
640+
.mockResolvedValueOnce({
641+
ok: true,
642+
json: async () => ([{ email: 'alice@example.com', primary: true, verified: true }]),
643+
});
644+
645+
const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } });
646+
const res = makeRes();
647+
648+
await controller.handleSocialAuthCallback(req, res);
649+
650+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
651+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('deletion'));
652+
});
653+
654+
test('handleSocialAuthCallback rejects soft-deleted user by verified email', async () => {
655+
redis.get.mockResolvedValueOnce(JSON.stringify({
656+
projectId: 'project_1',
657+
provider: 'github',
658+
callbackUrl: 'http://localhost:5173/auth/callback',
659+
}));
660+
mockProjectFindByIdChain.lean.mockResolvedValueOnce(makeProject());
661+
662+
mockUsersModel.findOne
663+
.mockResolvedValueOnce(null)
664+
.mockResolvedValueOnce({
665+
_id: 'deleted_user',
666+
email: 'alice@example.com',
667+
isDeleted: true,
668+
deletedAt: new Date().toISOString()
669+
});
670+
671+
global.fetch
672+
.mockResolvedValueOnce({
673+
ok: true,
674+
json: async () => ({ access_token: 'github_access_token' }),
675+
})
676+
.mockResolvedValueOnce({
677+
ok: true,
678+
json: async () => ({ id: 123, login: 'alice', avatar_url: '' }),
679+
})
680+
.mockResolvedValueOnce({
681+
ok: true,
682+
json: async () => ([{ email: 'alice@example.com', verified: true, primary: true }]),
683+
});
684+
685+
const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } });
686+
const res = makeRes();
687+
688+
await controller.handleSocialAuthCallback(req, res);
689+
690+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error='));
691+
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('deletion'));
692+
});
614693
});

0 commit comments

Comments
 (0)