Skip to content

Commit 5a8882e

Browse files
committedSep 8, 2021
Merge branch 'release/1.19.0'
2 parents 671ab35 + d747ba1 commit 5a8882e

38 files changed

+1013
-522
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm'
2+
3+
export class addCancelledFlag1630905831679 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query('ALTER TABLE `user_subscriptions` ADD `cancelled` tinyint NOT NULL DEFAULT 0')
6+
}
7+
8+
public async down(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query('ALTER TABLE `user_subscriptions` DROP COLUMN `cancelled`')
10+
}
11+
}

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"@standardnotes/common": "1.0.0",
2828
"@standardnotes/domain-events": "2.1.0",
2929
"@standardnotes/domain-events-infra": "1.0.1",
30-
"@standardnotes/features": "1.3.0",
30+
"@standardnotes/features": "1.6.2",
3131
"@standardnotes/settings": "1.1.0",
3232
"@standardnotes/sncrypto-common": "1.3.0",
3333
"@standardnotes/sncrypto-node": "1.3.0",

‎src/Bootstrap/Container.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import { TokenDecoder } from '../Domain/Auth/TokenDecoder'
4848
import { AuthenticationMethodResolver } from '../Domain/Auth/AuthenticationMethodResolver'
4949
import { RevokedSession } from '../Domain/Session/RevokedSession'
5050
import { UserRegisteredEventHandler } from '../Domain/Handler/UserRegisteredEventHandler'
51-
import { ChangePassword } from '../Domain/UseCase/ChangePassword'
5251
import { DomainEventFactory } from '../Domain/Event/DomainEventFactory'
5352
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
5453
import { Role } from '../Domain/Role/Role'
@@ -96,6 +95,8 @@ import { FeatureService } from '../Domain/Feature/FeatureService'
9695
import { SettingServiceInterface } from '../Domain/Setting/SettingServiceInterface'
9796
import { ExtensionKeyGrantedEventHandler } from '../Domain/Handler/ExtensionKeyGrantedEventHandler'
9897
import { RedisDomainEventPublisher, RedisDomainEventSubscriberFactory, RedisEventMessageHandler, SNSDomainEventPublisher, SQSDomainEventSubscriberFactory, SQSEventMessageHandler, SQSNewRelicEventMessageHandler } from '@standardnotes/domain-events-infra'
98+
import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription'
99+
import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials'
99100

100101
export class ContainerConfigLoader {
101102
async load(): Promise<Container> {
@@ -247,7 +248,7 @@ export class ContainerConfigLoader {
247248
container.bind<GetActiveSessionsForUser>(TYPES.GetActiveSessionsForUser).to(GetActiveSessionsForUser)
248249
container.bind<DeletePreviousSessionsForUser>(TYPES.DeletePreviousSessionsForUser).to(DeletePreviousSessionsForUser)
249250
container.bind<DeleteSessionForUser>(TYPES.DeleteSessionForUser).to(DeleteSessionForUser)
250-
container.bind<ChangePassword>(TYPES.ChangePassword).to(ChangePassword)
251+
container.bind<ChangeCredentials>(TYPES.ChangeCredentials).to(ChangeCredentials)
251252
container.bind<GetSettings>(TYPES.GetSettings).to(GetSettings)
252253
container.bind<GetSetting>(TYPES.GetSetting).to(GetSetting)
253254
container.bind<GetUserFeatures>(TYPES.GetUserFeatures).to(GetUserFeatures)
@@ -257,6 +258,7 @@ export class ContainerConfigLoader {
257258
container.bind<DeleteAccount>(TYPES.DeleteAccount).to(DeleteAccount)
258259
container.bind<AddWebSocketsConnection>(TYPES.AddWebSocketsConnection).to(AddWebSocketsConnection)
259260
container.bind<RemoveWebSocketsConnection>(TYPES.RemoveWebSocketsConnection).to(RemoveWebSocketsConnection)
261+
container.bind<GetUserSubscription>(TYPES.GetUserSubscription).to(GetUserSubscription)
260262

261263
// Handlers
262264
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)

‎src/Bootstrap/Types.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const TYPES = {
6868
GetActiveSessionsForUser: Symbol.for('GetActiveSessionsForUser'),
6969
DeletePreviousSessionsForUser: Symbol.for('DeletePreviousSessionsForUser'),
7070
DeleteSessionForUser: Symbol.for('DeleteSessionForUser'),
71-
ChangePassword: Symbol.for('ChangePassword'),
71+
ChangeCredentials: Symbol.for('ChangePassword'),
7272
GetSettings: Symbol.for('GetSettings'),
7373
GetSetting: Symbol.for('GetSetting'),
7474
GetUserFeatures: Symbol.for('GetUserFeatures'),
@@ -78,6 +78,7 @@ const TYPES = {
7878
DeleteAccount: Symbol.for('DeleteAccount'),
7979
AddWebSocketsConnection: Symbol.for('AddWebSocketsConnection'),
8080
RemoveWebSocketsConnection: Symbol.for('RemoveWebSocketsConnection'),
81+
GetUserSubscription: Symbol.for('GetUserSubscription'),
8182
// Handlers
8283
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
8384
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),

‎src/Controller/AuthController.spec.ts

-113
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyP
1616
import { User } from '../Domain/User/User'
1717
import { Session } from '../Domain/Session/Session'
1818
import { Register } from '../Domain/UseCase/Register'
19-
import { ChangePassword } from '../Domain/UseCase/ChangePassword'
2019
import { GetAuthMethods } from '../Domain/UseCase/GetAuthMethods/GetAuthMethods'
2120
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
2221

@@ -28,7 +27,6 @@ describe('AuthController', () => {
2827
let clearLoginAttempts: ClearLoginAttempts
2928
let increaseLoginAttempts: IncreaseLoginAttempts
3029
let register: Register
31-
let changePassword: ChangePassword
3230
let domainEventPublisher: DomainEventPublisherInterface
3331
let domainEventFactory: DomainEventFactoryInterface
3432
let event: DomainEventInterface
@@ -47,7 +45,6 @@ describe('AuthController', () => {
4745
clearLoginAttempts,
4846
increaseLoginAttempts,
4947
register,
50-
changePassword,
5148
domainEventPublisher,
5249
domainEventFactory,
5350
logger,
@@ -70,9 +67,6 @@ describe('AuthController', () => {
7067
register = {} as jest.Mocked<Register>
7168
register.execute = jest.fn()
7269

73-
changePassword = {} as jest.Mocked<ChangePassword>
74-
changePassword.execute = jest.fn()
75-
7670
user = {} as jest.Mocked<User>
7771
user.email = 'test@test.te'
7872

@@ -138,113 +132,6 @@ describe('AuthController', () => {
138132
expect(await result.content.readAsStringAsync()).toEqual('{"user":{"email":"test@test.te"}}')
139133
})
140134

141-
it('should change a password', async () => {
142-
request.body.version = '004'
143-
request.body.api = '20190520'
144-
request.body.current_password = 'test123'
145-
request.body.new_password = 'test234'
146-
request.body.pw_nonce = 'asdzxc'
147-
request.body.origination = 'change-password'
148-
request.body.created = '123'
149-
request.headers['user-agent'] = 'Google Chrome'
150-
response.locals.user = user
151-
152-
changePassword.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } })
153-
154-
const httpResponse = <results.JsonResult> await createController().changePassword(request, response)
155-
const result = await httpResponse.executeAsync()
156-
157-
expect(changePassword.execute).toHaveBeenCalledWith({
158-
apiVersion: '20190520',
159-
updatedWithUserAgent: 'Google Chrome',
160-
currentPassword: 'test123',
161-
newPassword: 'test234',
162-
kpCreated: '123',
163-
kpOrigination: 'change-password',
164-
pwNonce: 'asdzxc',
165-
protocolVersion: '004',
166-
user: {
167-
email: 'test@test.te',
168-
},
169-
})
170-
171-
expect(clearLoginAttempts.execute).toHaveBeenCalled()
172-
173-
expect(result.statusCode).toEqual(200)
174-
expect(await result.content.readAsStringAsync()).toEqual('{"foo":"bar"}')
175-
})
176-
177-
it('should indicate if changing a password fails', async () => {
178-
request.body.version = '004'
179-
request.body.api = '20190520'
180-
request.body.current_password = 'test123'
181-
request.body.new_password = 'test234'
182-
request.body.pw_nonce = 'asdzxc'
183-
request.headers['user-agent'] = 'Google Chrome'
184-
response.locals.user = user
185-
186-
changePassword.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' })
187-
188-
const httpResponse = <results.JsonResult> await createController().changePassword(request, response)
189-
const result = await httpResponse.executeAsync()
190-
191-
expect(increaseLoginAttempts.execute).toHaveBeenCalled()
192-
193-
expect(result.statusCode).toEqual(401)
194-
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Something bad happened"}}')
195-
})
196-
197-
it('should not change a password if current password is missing', async () => {
198-
request.body.version = '004'
199-
request.body.api = '20190520'
200-
request.body.new_password = 'test234'
201-
request.body.pw_nonce = 'asdzxc'
202-
request.headers['user-agent'] = 'Google Chrome'
203-
response.locals.user = user
204-
205-
const httpResponse = <results.JsonResult> await createController().changePassword(request, response)
206-
const result = await httpResponse.executeAsync()
207-
208-
expect(changePassword.execute).not.toHaveBeenCalled()
209-
210-
expect(result.statusCode).toEqual(400)
211-
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Your current password is required to change your password. Please update your application if you do not see this option."}}')
212-
})
213-
214-
it('should not change a password if new password is missing', async () => {
215-
request.body.version = '004'
216-
request.body.api = '20190520'
217-
request.body.current_password = 'test123'
218-
request.body.pw_nonce = 'asdzxc'
219-
request.headers['user-agent'] = 'Google Chrome'
220-
response.locals.user = user
221-
222-
const httpResponse = <results.JsonResult> await createController().changePassword(request, response)
223-
const result = await httpResponse.executeAsync()
224-
225-
expect(changePassword.execute).not.toHaveBeenCalled()
226-
227-
expect(result.statusCode).toEqual(400)
228-
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Your new password is required to change your password. Please try again."}}')
229-
})
230-
231-
it('should not change a password if password nonce is missing', async () => {
232-
request.body.version = '004'
233-
request.body.api = '20190520'
234-
request.body.current_password = 'test123'
235-
request.body.new_password = 'test234'
236-
request.headers['user-agent'] = 'Google Chrome'
237-
response.locals.user = user
238-
239-
const httpResponse = <results.JsonResult> await createController().changePassword(request, response)
240-
const result = await httpResponse.executeAsync()
241-
242-
expect(changePassword.execute).not.toHaveBeenCalled()
243-
244-
expect(result.statusCode).toEqual(400)
245-
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"The change password request is missing new auth parameters. Please try again."}}')
246-
})
247-
248135
it('should register a user - with 001 version', async () => {
249136
request.body.email = 'test@test.te'
250137
request.body.password = 'asdzxc'

‎src/Controller/AuthController.ts

-55
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts'
1919
import { Logger } from 'winston'
2020
import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
2121
import { Register } from '../Domain/UseCase/Register'
22-
import { ChangePassword } from '../Domain/UseCase/ChangePassword'
2322
import { GetAuthMethods } from '../Domain/UseCase/GetAuthMethods/GetAuthMethods'
2423
import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
2524

@@ -33,7 +32,6 @@ export class AuthController extends BaseHttpController {
3332
@inject(TYPES.ClearLoginAttempts) private clearLoginAttempts: ClearLoginAttempts,
3433
@inject(TYPES.IncreaseLoginAttempts) private increaseLoginAttempts: IncreaseLoginAttempts,
3534
@inject(TYPES.Register) private registerUser: Register,
36-
@inject(TYPES.ChangePassword) private changePasswordUseCase: ChangePassword,
3735
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
3836
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
3937
@inject(TYPES.Logger) private logger: Logger,
@@ -156,59 +154,6 @@ export class AuthController extends BaseHttpController {
156154
return this.statusCode(204)
157155
}
158156

159-
@httpPost('/change_pw', TYPES.AuthMiddleware)
160-
async changePassword(request: Request, response: Response): Promise<results.JsonResult> {
161-
if (!request.body.current_password) {
162-
return this.json({
163-
error: {
164-
message: 'Your current password is required to change your password. Please update your application if you do not see this option.',
165-
},
166-
}, 400)
167-
}
168-
169-
if (!request.body.new_password) {
170-
return this.json({
171-
error: {
172-
message: 'Your new password is required to change your password. Please try again.',
173-
},
174-
}, 400)
175-
}
176-
177-
if (!request.body.pw_nonce) {
178-
return this.json({
179-
error: {
180-
message: 'The change password request is missing new auth parameters. Please try again.',
181-
},
182-
}, 400)
183-
}
184-
185-
const changePasswordResult = await this.changePasswordUseCase.execute({
186-
user: response.locals.user,
187-
apiVersion: request.body.api,
188-
currentPassword: request.body.current_password,
189-
newPassword: request.body.new_password,
190-
pwNonce: request.body.pw_nonce,
191-
kpCreated: request.body.created,
192-
kpOrigination: request.body.origination,
193-
updatedWithUserAgent: <string> request.headers['user-agent'],
194-
protocolVersion: request.body.version,
195-
})
196-
197-
if (!changePasswordResult.success) {
198-
await this.increaseLoginAttempts.execute({ email: response.locals.user.email })
199-
200-
return this.json({
201-
error: {
202-
message: changePasswordResult.errorMessage,
203-
},
204-
}, 401)
205-
}
206-
207-
await this.clearLoginAttempts.execute({ email: response.locals.user.email })
208-
209-
return this.json(changePasswordResult.authResponse)
210-
}
211-
212157
@httpPost('/')
213158
async register(request: Request): Promise<results.JsonResult> {
214159
if (!request.body.email || !request.body.password) {

‎src/Controller/UsersController.spec.ts

+190-4
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ import { User } from '../Domain/User/User'
88
import { UpdateUser } from '../Domain/UseCase/UpdateUser'
99
import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
1010
import { DeleteAccount } from '../Domain/UseCase/DeleteAccount/DeleteAccount'
11+
import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription'
12+
import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts'
13+
import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts'
14+
import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials'
1115

1216
describe('UsersController', () => {
1317
let updateUser: UpdateUser
1418
let deleteAccount: DeleteAccount
1519
let getUserKeyParams: GetUserKeyParams
20+
let getUserSubscription: GetUserSubscription
21+
let clearLoginAttempts: ClearLoginAttempts
22+
let increaseLoginAttempts: IncreaseLoginAttempts
23+
let changeCredentials: ChangeCredentials
1624

1725
let request: express.Request
1826
let response: express.Response
@@ -22,6 +30,10 @@ describe('UsersController', () => {
2230
updateUser,
2331
getUserKeyParams,
2432
deleteAccount,
33+
getUserSubscription,
34+
clearLoginAttempts,
35+
increaseLoginAttempts,
36+
changeCredentials
2537
)
2638

2739
beforeEach(() => {
@@ -33,10 +45,23 @@ describe('UsersController', () => {
3345

3446
user = {} as jest.Mocked<User>
3547
user.uuid = '123'
48+
user.email = 'test@test.te'
3649

3750
getUserKeyParams = {} as jest.Mocked<GetUserKeyParams>
3851
getUserKeyParams.execute = jest.fn()
3952

53+
getUserSubscription = {} as jest.Mocked<GetUserSubscription>
54+
getUserSubscription.execute = jest.fn()
55+
56+
changeCredentials = {} as jest.Mocked<ChangeCredentials>
57+
changeCredentials.execute = jest.fn()
58+
59+
clearLoginAttempts = {} as jest.Mocked<ClearLoginAttempts>
60+
clearLoginAttempts.execute = jest.fn()
61+
62+
increaseLoginAttempts = {} as jest.Mocked<IncreaseLoginAttempts>
63+
increaseLoginAttempts.execute = jest.fn()
64+
4065
request = {
4166
headers: {},
4267
body: {},
@@ -52,7 +77,6 @@ describe('UsersController', () => {
5277
request.body.version = '002'
5378
request.body.api = '20190520'
5479
request.body.origination = 'test'
55-
request.body.email = 'newemail@test.te'
5680
request.params.userId = '123'
5781
request.headers['user-agent'] = 'Google Chrome'
5882
response.locals.user = user
@@ -66,10 +90,10 @@ describe('UsersController', () => {
6690
apiVersion: '20190520',
6791
kpOrigination: 'test',
6892
updatedWithUserAgent: 'Google Chrome',
69-
email: 'newemail@test.te',
7093
version: '002',
7194
user: {
7295
uuid: '123',
96+
email: 'test@test.te',
7397
},
7498
})
7599

@@ -81,7 +105,6 @@ describe('UsersController', () => {
81105
request.body.version = '002'
82106
request.body.api = '20190520'
83107
request.body.origination = 'test'
84-
request.body.email = 'newemail@test.te'
85108
request.params.userId = '123'
86109
request.headers['user-agent'] = 'Google Chrome'
87110
response.locals.user = user
@@ -95,10 +118,10 @@ describe('UsersController', () => {
95118
apiVersion: '20190520',
96119
kpOrigination: 'test',
97120
updatedWithUserAgent: 'Google Chrome',
98-
email: 'newemail@test.te',
99121
version: '002',
100122
user: {
101123
uuid: '123',
124+
email: 'test@test.te',
102125
},
103126
})
104127

@@ -189,4 +212,167 @@ describe('UsersController', () => {
189212

190213
expect(result.statusCode).toEqual(400)
191214
})
215+
216+
it('should get user subscription', async () => {
217+
request.params.userUuid = '1-2-3'
218+
response.locals.user = {
219+
uuid: '1-2-3',
220+
}
221+
222+
getUserSubscription.execute = jest.fn().mockReturnValue({
223+
success: true,
224+
})
225+
226+
const httpResponse = <results.JsonResult> await createController().getSubscription(request, response)
227+
const result = await httpResponse.executeAsync()
228+
229+
expect(getUserSubscription.execute).toHaveBeenCalledWith({
230+
userUuid: '1-2-3',
231+
})
232+
233+
expect(result.statusCode).toEqual(200)
234+
})
235+
236+
it('should not get user subscription if the user with provided uuid does not exist', async () => {
237+
request.params.userUuid = '1-2-3'
238+
response.locals.user = {
239+
uuid: '1-2-3',
240+
}
241+
242+
getUserSubscription.execute = jest.fn().mockReturnValue({
243+
success: false,
244+
})
245+
246+
const httpResponse = <results.JsonResult> await createController().getSubscription(request, response)
247+
const result = await httpResponse.executeAsync()
248+
249+
expect(getUserSubscription.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' })
250+
251+
expect(result.statusCode).toEqual(400)
252+
253+
})
254+
255+
it('should not get user subscription if not allowed', async () => {
256+
request.params.userUuid = '1-2-3'
257+
response.locals.user = {
258+
uuid: '2-3-4',
259+
}
260+
261+
getUserSubscription.execute = jest.fn()
262+
263+
const httpResponse = <results.JsonResult> await createController().getSubscription(request, response)
264+
const result = await httpResponse.executeAsync()
265+
266+
expect(getUserSubscription.execute).not.toHaveBeenCalled()
267+
268+
expect(result.statusCode).toEqual(401)
269+
})
270+
271+
it('should change a password', async () => {
272+
request.body.version = '004'
273+
request.body.api = '20190520'
274+
request.body.current_password = 'test123'
275+
request.body.new_password = 'test234'
276+
request.body.pw_nonce = 'asdzxc'
277+
request.body.origination = 'change-password'
278+
request.body.created = '123'
279+
request.headers['user-agent'] = 'Google Chrome'
280+
response.locals.user = user
281+
282+
changeCredentials.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } })
283+
284+
const httpResponse = <results.JsonResult> await createController().changeCredentials(request, response)
285+
const result = await httpResponse.executeAsync()
286+
287+
expect(changeCredentials.execute).toHaveBeenCalledWith({
288+
apiVersion: '20190520',
289+
updatedWithUserAgent: 'Google Chrome',
290+
currentPassword: 'test123',
291+
newPassword: 'test234',
292+
kpCreated: '123',
293+
kpOrigination: 'change-password',
294+
pwNonce: 'asdzxc',
295+
protocolVersion: '004',
296+
user: {
297+
uuid: '123',
298+
email: 'test@test.te',
299+
},
300+
})
301+
302+
expect(clearLoginAttempts.execute).toHaveBeenCalled()
303+
304+
expect(result.statusCode).toEqual(200)
305+
expect(await result.content.readAsStringAsync()).toEqual('{"foo":"bar"}')
306+
})
307+
308+
it('should indicate if changing a password fails', async () => {
309+
request.body.version = '004'
310+
request.body.api = '20190520'
311+
request.body.current_password = 'test123'
312+
request.body.new_password = 'test234'
313+
request.body.pw_nonce = 'asdzxc'
314+
request.headers['user-agent'] = 'Google Chrome'
315+
response.locals.user = user
316+
317+
changeCredentials.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' })
318+
319+
const httpResponse = <results.JsonResult> await createController().changeCredentials(request, response)
320+
const result = await httpResponse.executeAsync()
321+
322+
expect(increaseLoginAttempts.execute).toHaveBeenCalled()
323+
324+
expect(result.statusCode).toEqual(401)
325+
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Something bad happened"}}')
326+
})
327+
328+
it('should not change a password if current password is missing', async () => {
329+
request.body.version = '004'
330+
request.body.api = '20190520'
331+
request.body.new_password = 'test234'
332+
request.body.pw_nonce = 'asdzxc'
333+
request.headers['user-agent'] = 'Google Chrome'
334+
response.locals.user = user
335+
336+
const httpResponse = <results.JsonResult> await createController().changeCredentials(request, response)
337+
const result = await httpResponse.executeAsync()
338+
339+
expect(changeCredentials.execute).not.toHaveBeenCalled()
340+
341+
expect(result.statusCode).toEqual(400)
342+
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Your current password is required to change your password. Please update your application if you do not see this option."}}')
343+
})
344+
345+
it('should not change a password if new password is missing', async () => {
346+
request.body.version = '004'
347+
request.body.api = '20190520'
348+
request.body.current_password = 'test123'
349+
request.body.pw_nonce = 'asdzxc'
350+
request.headers['user-agent'] = 'Google Chrome'
351+
response.locals.user = user
352+
353+
const httpResponse = <results.JsonResult> await createController().changeCredentials(request, response)
354+
const result = await httpResponse.executeAsync()
355+
356+
expect(changeCredentials.execute).not.toHaveBeenCalled()
357+
358+
expect(result.statusCode).toEqual(400)
359+
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Your new password is required to change your password. Please try again."}}')
360+
})
361+
362+
it('should not change a password if password nonce is missing', async () => {
363+
request.body.version = '004'
364+
request.body.api = '20190520'
365+
request.body.current_password = 'test123'
366+
request.body.new_password = 'test234'
367+
request.headers['user-agent'] = 'Google Chrome'
368+
response.locals.user = user
369+
370+
const httpResponse = <results.JsonResult> await createController().changeCredentials(request, response)
371+
const result = await httpResponse.executeAsync()
372+
373+
expect(changeCredentials.execute).not.toHaveBeenCalled()
374+
375+
expect(result.statusCode).toEqual(400)
376+
expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"The change password request is missing new auth parameters. Please try again."}}')
377+
})
192378
})

‎src/Controller/UsersController.ts

+84-1
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,29 @@ import {
66
httpDelete,
77
httpGet,
88
httpPatch,
9+
httpPut,
910
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1011
results,
1112
} from 'inversify-express-utils'
1213
import TYPES from '../Bootstrap/Types'
1314
import { DeleteAccount } from '../Domain/UseCase/DeleteAccount/DeleteAccount'
1415
import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
1516
import { UpdateUser } from '../Domain/UseCase/UpdateUser'
17+
import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription'
18+
import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts'
19+
import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts'
20+
import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials'
1621

1722
@controller('/users')
1823
export class UsersController extends BaseHttpController {
1924
constructor(
2025
@inject(TYPES.UpdateUser) private updateUser: UpdateUser,
2126
@inject(TYPES.GetUserKeyParams) private getUserKeyParams: GetUserKeyParams,
2227
@inject(TYPES.DeleteAccount) private doDeleteAccount: DeleteAccount,
28+
@inject(TYPES.GetUserSubscription) private doGetUserSubscription: GetUserSubscription,
29+
@inject(TYPES.ClearLoginAttempts) private clearLoginAttempts: ClearLoginAttempts,
30+
@inject(TYPES.IncreaseLoginAttempts) private increaseLoginAttempts: IncreaseLoginAttempts,
31+
@inject(TYPES.ChangeCredentials) private changeCredentialsUseCase: ChangeCredentials,
2332
) {
2433
super()
2534
}
@@ -40,7 +49,6 @@ export class UsersController extends BaseHttpController {
4049
apiVersion: request.body.api,
4150
pwFunc: request.body.pw_func,
4251
pwAlg: request.body.pw_alg,
43-
email: request.body.email,
4452
pwCost: request.body.pw_cost,
4553
pwKeySize: request.body.pw_key_size,
4654
pwNonce: request.body.pw_nonce,
@@ -91,4 +99,79 @@ export class UsersController extends BaseHttpController {
9199

92100
return this.json({ message: result.message }, result.responseCode)
93101
}
102+
103+
@httpGet('/:userUuid/subscription', TYPES.AuthMiddleware)
104+
async getSubscription(request: Request, response: Response): Promise<results.JsonResult> {
105+
if (request.params.userUuid !== response.locals.user.uuid) {
106+
return this.json({
107+
error: {
108+
message: 'Operation not allowed.',
109+
},
110+
}, 401)
111+
}
112+
113+
const result = await this.doGetUserSubscription.execute({
114+
userUuid: request.params.userUuid,
115+
})
116+
117+
if (result.success) {
118+
return this.json(result)
119+
}
120+
121+
return this.json(result, 400)
122+
}
123+
124+
@httpPut('/:userId/attributes/credentials', TYPES.AuthMiddleware)
125+
async changeCredentials(request: Request, response: Response): Promise<results.JsonResult> {
126+
if (!request.body.current_password) {
127+
return this.json({
128+
error: {
129+
message: 'Your current password is required to change your password. Please update your application if you do not see this option.',
130+
},
131+
}, 400)
132+
}
133+
134+
if (!request.body.new_password) {
135+
return this.json({
136+
error: {
137+
message: 'Your new password is required to change your password. Please try again.',
138+
},
139+
}, 400)
140+
}
141+
142+
if (!request.body.pw_nonce) {
143+
return this.json({
144+
error: {
145+
message: 'The change password request is missing new auth parameters. Please try again.',
146+
},
147+
}, 400)
148+
}
149+
150+
const changeCredentialsResult = await this.changeCredentialsUseCase.execute({
151+
user: response.locals.user,
152+
apiVersion: request.body.api,
153+
currentPassword: request.body.current_password,
154+
newPassword: request.body.new_password,
155+
newEmail: request.body.new_email,
156+
pwNonce: request.body.pw_nonce,
157+
kpCreated: request.body.created,
158+
kpOrigination: request.body.origination,
159+
updatedWithUserAgent: <string> request.headers['user-agent'],
160+
protocolVersion: request.body.version,
161+
})
162+
163+
if (!changeCredentialsResult.success) {
164+
await this.increaseLoginAttempts.execute({ email: response.locals.user.email })
165+
166+
return this.json({
167+
error: {
168+
message: changeCredentialsResult.errorMessage,
169+
},
170+
}, 401)
171+
}
172+
173+
await this.clearLoginAttempts.execute({ email: response.locals.user.email })
174+
175+
return this.json(changeCredentialsResult.authResponse)
176+
}
94177
}

‎src/Domain/Feature/FeatureService.spec.ts

+91-89
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe('FeatureService', () => {
5959
planName: SubscriptionName.CorePlan,
6060
endsAt: 555,
6161
user: Promise.resolve(user),
62+
cancelled: false,
6263
}
6364

6465
subscription2 = {
@@ -68,6 +69,7 @@ describe('FeatureService', () => {
6869
planName: SubscriptionName.ProPlan,
6970
endsAt: 777,
7071
user: Promise.resolve(user),
72+
cancelled: false,
7173
}
7274

7375
user = {
@@ -85,27 +87,27 @@ describe('FeatureService', () => {
8587
settingService.findSetting = jest.fn().mockReturnValue(extensionKeySetting)
8688
})
8789

88-
it('should return user features with `expiresAt` field', async () => {
90+
it('should return user features with `expires_at` field', async () => {
8991
expect(await createService().getFeaturesForUser(user)).toEqual([
9092
{
91-
'contentType': 'SN|Theme',
93+
'content_type': 'SN|Theme',
9294
'description': 'A theme for writers and readers.',
93-
'dockIcon': {
94-
'backgroundColor': '#9D7441',
95-
'borderColor': '#9D7441',
96-
'foregroundColor': '#ECE4DB',
95+
'dock_icon': {
96+
'background_color': '#9D7441',
97+
'border_color': '#9D7441',
98+
'foreground_color': '#ECE4DB',
9799
'type': 'circle',
98100
},
99-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
100-
'expiresAt': 555,
101+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
102+
'expires_at': 555,
101103
'flags': [
102104
'New',
103105
],
104106
'identifier': 'org.standardnotes.theme-autobiography',
105-
'permissionName': 'theme:autobiography',
106-
'marketingUrl': '',
107+
'permission_name': 'theme:autobiography',
108+
'marketing_url': '',
107109
'name': 'Autobiography',
108-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
110+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
109111
'url': 'https://extension-server/abc123/themes/autobiography',
110112
'version': '1.0.0',
111113
},
@@ -129,24 +131,24 @@ describe('FeatureService', () => {
129131

130132
expect(await createService().getFeaturesForUser(user)).toEqual([
131133
{
132-
'contentType': 'SN|Theme',
134+
'content_type': 'SN|Theme',
133135
'description': 'A theme for writers and readers.',
134-
'dockIcon': {
135-
'backgroundColor': '#9D7441',
136-
'borderColor': '#9D7441',
137-
'foregroundColor': '#ECE4DB',
136+
'dock_icon': {
137+
'background_color': '#9D7441',
138+
'border_color': '#9D7441',
139+
'foreground_color': '#ECE4DB',
138140
'type': 'circle',
139141
},
140-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
141-
'expiresAt': 555,
142+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
143+
'expires_at': 555,
142144
'flags': [
143145
'New',
144146
],
145147
'identifier': 'org.standardnotes.theme-autobiography',
146-
'permissionName': 'theme:autobiography',
147-
'marketingUrl': '',
148+
'permission_name': 'theme:autobiography',
149+
'marketing_url': '',
148150
'name': 'Autobiography',
149-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
151+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
150152
'url': '#{url_prefix}/themes/autobiography',
151153
'version': '1.0.0',
152154
},
@@ -158,31 +160,31 @@ describe('FeatureService', () => {
158160

159161
expect(await createService().getFeaturesForUser(user)).toEqual([
160162
{
161-
'contentType': 'SN|Theme',
163+
'content_type': 'SN|Theme',
162164
'description': 'A theme for writers and readers.',
163-
'dockIcon': {
164-
'backgroundColor': '#9D7441',
165-
'borderColor': '#9D7441',
166-
'foregroundColor': '#ECE4DB',
165+
'dock_icon': {
166+
'background_color': '#9D7441',
167+
'border_color': '#9D7441',
168+
'foreground_color': '#ECE4DB',
167169
'type': 'circle',
168170
},
169-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
170-
'expiresAt': 555,
171+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
172+
'expires_at': 555,
171173
'flags': [
172174
'New',
173175
],
174176
'identifier': 'org.standardnotes.theme-autobiography',
175-
'permissionName': 'theme:autobiography',
176-
'marketingUrl': '',
177+
'permission_name': 'theme:autobiography',
178+
'marketing_url': '',
177179
'name': 'Autobiography',
178-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
180+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
179181
'url': '#{url_prefix}/themes/autobiography',
180182
'version': '1.0.0',
181183
},
182184
])
183185
})
184186

185-
it('should return user features with `expiresAt` field when user has more than 1 role & subscription', async () => {
187+
it('should return user features with `expires_at` field when user has more than 1 role & subscription', async () => {
186188
roleToSubscriptionMap.getSubscriptionNameForRoleName = jest.fn()
187189
.mockReturnValueOnce(SubscriptionName.CorePlan)
188190
.mockReturnValueOnce(SubscriptionName.ProPlan)
@@ -195,39 +197,39 @@ describe('FeatureService', () => {
195197

196198
expect(await createService().getFeaturesForUser(user)).toEqual([
197199
{
198-
'contentType': 'SN|Theme',
200+
'content_type': 'SN|Theme',
199201
'description': 'A theme for writers and readers.',
200-
'dockIcon': {
201-
'backgroundColor': '#9D7441',
202-
'borderColor': '#9D7441',
203-
'foregroundColor': '#ECE4DB',
202+
'dock_icon': {
203+
'background_color': '#9D7441',
204+
'border_color': '#9D7441',
205+
'foreground_color': '#ECE4DB',
204206
'type': 'circle',
205207
},
206-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
207-
'expiresAt': 555,
208+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
209+
'expires_at': 555,
208210
'flags': [
209211
'New',
210212
],
211213
'identifier': 'org.standardnotes.theme-autobiography',
212-
'permissionName': 'theme:autobiography',
213-
'marketingUrl': '',
214+
'permission_name': 'theme:autobiography',
215+
'marketing_url': '',
214216
'name': 'Autobiography',
215-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
217+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
216218
'url': 'https://extension-server/abc123/themes/autobiography',
217219
'version': '1.0.0',
218220
},
219221
{
220222
'area': 'modal',
221-
'contentType': 'SN|Component',
222-
'description': '',
223-
'downloadUrl': '',
224-
'expiresAt': 777,
223+
'content_type': 'SN|Component',
224+
'description': 'Manage and install cloud backups, including Note History, Dropbox, Google Drive, OneDrive, and Daily Email Backups.',
225+
'download_url': '',
226+
'expires_at': 777,
225227
'identifier': 'org.standardnotes.cloudlink',
226-
'permissionName': 'component:cloud-link',
227-
'marketingUrl': '',
228-
'name': '',
229-
'url': '',
230-
'version': '',
228+
'permission_name': 'component:cloud-link',
229+
'marketing_url': '',
230+
'name': 'CloudLink',
231+
'url': 'https://extension-server/abc123/components/cloudlink',
232+
'version': '1.2.3',
231233
},
232234
])
233235
})
@@ -249,39 +251,39 @@ describe('FeatureService', () => {
249251

250252
expect(await createService().getFeaturesForUser(user)).toEqual([
251253
{
252-
'contentType': 'SN|Theme',
254+
'content_type': 'SN|Theme',
253255
'description': 'A theme for writers and readers.',
254-
'dockIcon': {
255-
'backgroundColor': '#9D7441',
256-
'borderColor': '#9D7441',
257-
'foregroundColor': '#ECE4DB',
256+
'dock_icon': {
257+
'background_color': '#9D7441',
258+
'border_color': '#9D7441',
259+
'foreground_color': '#ECE4DB',
258260
'type': 'circle',
259261
},
260-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
261-
'expiresAt': 777,
262+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
263+
'expires_at': 777,
262264
'flags': [
263265
'New',
264266
],
265267
'identifier': 'org.standardnotes.theme-autobiography',
266-
'permissionName': 'theme:autobiography',
267-
'marketingUrl': '',
268+
'permission_name': 'theme:autobiography',
269+
'marketing_url': '',
268270
'name': 'Autobiography',
269-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
271+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
270272
'url': 'https://extension-server/abc123/themes/autobiography',
271273
'version': '1.0.0',
272274
},
273275
{
274276
'area': 'modal',
275-
'contentType': 'SN|Component',
276-
'description': '',
277-
'downloadUrl': '',
278-
'expiresAt': 777,
277+
'content_type': 'SN|Component',
278+
'description': 'Manage and install cloud backups, including Note History, Dropbox, Google Drive, OneDrive, and Daily Email Backups.',
279+
'download_url': '',
280+
'expires_at': 777,
279281
'identifier': 'org.standardnotes.cloudlink',
280-
'permissionName': 'component:cloud-link',
281-
'marketingUrl': '',
282-
'name': '',
283-
'url': '',
284-
'version': '',
282+
'permission_name': 'component:cloud-link',
283+
'marketing_url': '',
284+
'name': 'CloudLink',
285+
'url': 'https://extension-server/abc123/components/cloudlink',
286+
'version': '1.2.3',
285287
},
286288
])
287289
})
@@ -305,39 +307,39 @@ describe('FeatureService', () => {
305307

306308
expect(await createService().getFeaturesForUser(user)).toEqual([
307309
{
308-
'contentType': 'SN|Theme',
310+
'content_type': 'SN|Theme',
309311
'description': 'A theme for writers and readers.',
310-
'dockIcon': {
311-
'backgroundColor': '#9D7441',
312-
'borderColor': '#9D7441',
313-
'foregroundColor': '#ECE4DB',
312+
'dock_icon': {
313+
'background_color': '#9D7441',
314+
'border_color': '#9D7441',
315+
'foreground_color': '#ECE4DB',
314316
'type': 'circle',
315317
},
316-
'downloadUrl': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
317-
'expiresAt': 555,
318+
'download_url': 'https://github.com/standardnotes/autobiography-theme/archive/1.0.0.zip',
319+
'expires_at': 555,
318320
'flags': [
319321
'New',
320322
],
321323
'identifier': 'org.standardnotes.theme-autobiography',
322-
'permissionName': 'theme:autobiography',
323-
'marketingUrl': '',
324+
'permission_name': 'theme:autobiography',
325+
'marketing_url': '',
324326
'name': 'Autobiography',
325-
'thumbnailUrl': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
327+
'thumbnail_url': 'https://s3.amazonaws.com/standard-notes/screenshots/models/themes/autobiography.jpg',
326328
'url': 'https://extension-server/abc123/themes/autobiography',
327329
'version': '1.0.0',
328330
},
329331
{
330332
'area': 'modal',
331-
'contentType': 'SN|Component',
332-
'description': '',
333-
'downloadUrl': '',
334-
'expiresAt': 111,
333+
'content_type': 'SN|Component',
334+
'description': 'Manage and install cloud backups, including Note History, Dropbox, Google Drive, OneDrive, and Daily Email Backups.',
335+
'download_url': '',
336+
'expires_at': 111,
335337
'identifier': 'org.standardnotes.cloudlink',
336-
'permissionName': 'component:cloud-link',
337-
'marketingUrl': '',
338-
'name': '',
339-
'url': '',
340-
'version': '',
338+
'permission_name': 'component:cloud-link',
339+
'marketing_url': '',
340+
'name': 'CloudLink',
341+
'url': 'https://extension-server/abc123/components/cloudlink',
342+
'version': '1.2.3',
341343
},
342344
])
343345
})

‎src/Domain/Feature/FeatureService.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { RoleName } from '@standardnotes/auth'
2-
import { Feature, Features } from '@standardnotes/features'
2+
import { FeatureDescription, Features } from '@standardnotes/features'
33
import { SettingName } from '@standardnotes/settings'
44
import { inject, injectable } from 'inversify'
55
import TYPES from '../../Bootstrap/Types'
@@ -20,7 +20,7 @@ export class FeatureService implements FeatureServiceInterface {
2020
) {
2121
}
2222

23-
async getFeaturesForUser(user: User): Promise<Array<Feature>> {
23+
async getFeaturesForUser(user: User): Promise<Array<FeatureDescription>> {
2424
const userRoles = await user.roles
2525
const userSubscriptions = await user.subscriptions
2626

@@ -29,7 +29,7 @@ export class FeatureService implements FeatureServiceInterface {
2929
userUuid: user.uuid,
3030
})
3131

32-
const userFeatures: Map<string, Feature> = new Map()
32+
const userFeatures: Map<string, FeatureDescription> = new Map()
3333
for (const role of userRoles) {
3434
const subscriptionName = this.roleToSubscriptionMap.getSubscriptionNameForRoleName(role.name as RoleName)
3535
const userSubscription = userSubscriptions.find(subscription => subscription.planName === subscriptionName) as UserSubscription
@@ -41,7 +41,7 @@ export class FeatureService implements FeatureServiceInterface {
4141
const rolePermissions = await role.permissions
4242

4343
for (const rolePermission of rolePermissions) {
44-
let featureForPermission = Features.find(feature => feature.permissionName === rolePermission.name) as Feature
44+
let featureForPermission = Features.find(feature => feature.permission_name === rolePermission.name) as FeatureDescription
4545

4646
if (extensionKeySetting !== undefined) {
4747
featureForPermission = {
@@ -54,13 +54,13 @@ export class FeatureService implements FeatureServiceInterface {
5454
if (alreadyAddedFeature === undefined) {
5555
userFeatures.set(rolePermission.name, {
5656
...featureForPermission,
57-
expiresAt,
57+
expires_at: expiresAt,
5858
})
5959
continue
6060
}
6161

62-
if (expiresAt > (alreadyAddedFeature.expiresAt as number)) {
63-
alreadyAddedFeature.expiresAt = expiresAt
62+
if (expiresAt > (alreadyAddedFeature.expires_at as number)) {
63+
alreadyAddedFeature.expires_at = expiresAt
6464
}
6565
}
6666
}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Feature } from '@standardnotes/features'
1+
import { FeatureDescription } from '@standardnotes/features'
22

33
import { User } from '../User/User'
44

55
export interface FeatureServiceInterface {
6-
getFeaturesForUser(user: User): Promise<Array<Feature>>
6+
getFeaturesForUser(user: User): Promise<Array<FeatureDescription>>
77
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import 'reflect-metadata'
2+
3+
import { SubscriptionName } from '@standardnotes/auth'
4+
import { SubscriptionCancelledEvent } from '@standardnotes/domain-events'
5+
import { Logger } from 'winston'
6+
7+
import * as dayjs from 'dayjs'
8+
9+
import { User } from '../User/User'
10+
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
11+
import { UserSubscriptionRepositoryInterface } from '../User/UserSubscriptionRepositoryInterface'
12+
import { SubscriptionCancelledEventHandler } from './SubscriptionCancelledEventHandler'
13+
14+
describe('SubscriptionCancelledEventHandler', () => {
15+
let userRepository: UserRepositoryInterface
16+
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
17+
let logger: Logger
18+
let user: User
19+
let event: SubscriptionCancelledEvent
20+
let timestamp: number
21+
22+
const createHandler = () => new SubscriptionCancelledEventHandler(
23+
userRepository,
24+
userSubscriptionRepository,
25+
logger
26+
)
27+
28+
beforeEach(() => {
29+
user = {
30+
uuid: '123',
31+
} as jest.Mocked<User>
32+
33+
userRepository = {} as jest.Mocked<UserRepositoryInterface>
34+
userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
35+
36+
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
37+
userSubscriptionRepository.updateCancelled = jest.fn()
38+
39+
timestamp = dayjs.utc().valueOf()
40+
41+
event = {} as jest.Mocked<SubscriptionCancelledEvent>
42+
event.createdAt = new Date(1)
43+
event.payload = {
44+
userEmail: 'test@test.com',
45+
subscriptionName: SubscriptionName.ProPlan,
46+
timestamp,
47+
}
48+
49+
logger = {} as jest.Mocked<Logger>
50+
logger.info = jest.fn()
51+
logger.warn = jest.fn()
52+
})
53+
54+
it('should update subscription cancelled', async () => {
55+
await createHandler().handle(event)
56+
57+
expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com')
58+
expect(
59+
userSubscriptionRepository.updateCancelled
60+
).toHaveBeenCalledWith(
61+
SubscriptionName.ProPlan,
62+
'123',
63+
true,
64+
timestamp,
65+
)
66+
})
67+
68+
it('should not do anything if no user is found for specified email', async () => {
69+
userRepository.findOneByEmail = jest.fn().mockReturnValue(undefined)
70+
71+
await createHandler().handle(event)
72+
73+
expect(userSubscriptionRepository.updateCancelled).not.toHaveBeenCalled()
74+
})
75+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
DomainEventHandlerInterface,
3+
SubscriptionCancelledEvent,
4+
} from '@standardnotes/domain-events'
5+
import { inject, injectable } from 'inversify'
6+
import { Logger } from 'winston'
7+
8+
import TYPES from '../../Bootstrap/Types'
9+
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
10+
import { UserSubscriptionRepositoryInterface } from '../User/UserSubscriptionRepositoryInterface'
11+
12+
@injectable()
13+
export class SubscriptionCancelledEventHandler
14+
implements DomainEventHandlerInterface
15+
{
16+
constructor(
17+
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
18+
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
19+
@inject(TYPES.Logger) private logger: Logger
20+
) {}
21+
async handle(
22+
event: SubscriptionCancelledEvent
23+
): Promise<void> {
24+
const user = await this.userRepository.findOneByEmail(
25+
event.payload.userEmail
26+
)
27+
28+
if (user === undefined) {
29+
this.logger.warn(
30+
`Could not find user with email: ${event.payload.userEmail}`
31+
)
32+
return
33+
}
34+
35+
await this.updateSubscriptionCancelled(
36+
event.payload.subscriptionName,
37+
user.uuid,
38+
event.payload.timestamp,
39+
)
40+
}
41+
42+
private async updateSubscriptionCancelled(
43+
subscriptionName: string,
44+
userUuid: string,
45+
timestamp: number,
46+
): Promise<void> {
47+
await this.userSubscriptionRepository.updateCancelled(
48+
subscriptionName,
49+
userUuid,
50+
true,
51+
timestamp,
52+
)
53+
}
54+
}

‎src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe('SubscriptionPurchasedEventHandler', () => {
8888
...subscription,
8989
createdAt: expect.any(Number),
9090
updatedAt: expect.any(Number),
91+
cancelled: false,
9192
})
9293
})
9394

‎src/Domain/Handler/SubscriptionPurchasedEventHandler.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { SubscriptionName } from '@standardnotes/auth'
22
import {
33
DomainEventHandlerInterface,
44
SubscriptionPurchasedEvent,
5-
SubscriptionRenewedEvent,
65
} from '@standardnotes/domain-events'
76
import { inject, injectable } from 'inversify'
87
import { Logger } from 'winston'
@@ -26,7 +25,7 @@ implements DomainEventHandlerInterface
2625
) {}
2726

2827
async handle(
29-
event: SubscriptionPurchasedEvent | SubscriptionRenewedEvent
28+
event: SubscriptionPurchasedEvent
3029
): Promise<void> {
3130
const user = await this.userRepository.findOneByEmail(
3231
event.payload.userEmail
@@ -68,6 +67,7 @@ implements DomainEventHandlerInterface
6867
subscription.createdAt = timestamp
6968
subscription.updatedAt = timestamp
7069
subscription.endsAt = subscriptionExpiresAt
70+
subscription.cancelled = false
7171

7272
await this.userSubscriptionRepository.save(subscription)
7373
}

‎src/Domain/Handler/SubscriptionRefundedEventHandler.ts

-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ implements DomainEventHandlerInterface
4343
event.payload.timestamp,
4444
)
4545
await this.removeUserRole(user, event.payload.subscriptionName)
46-
4746
}
4847

4948
private async removeUserRole(

‎src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'reflect-metadata'
22

33
import { SubscriptionName } from '@standardnotes/auth'
4-
import { SubscriptionPurchasedEvent } from '@standardnotes/domain-events'
4+
import { SubscriptionRenewedEvent } from '@standardnotes/domain-events'
55
import { Logger } from 'winston'
66

77
import * as dayjs from 'dayjs'
@@ -16,7 +16,7 @@ describe('SubscriptionRenewedEventHandler', () => {
1616
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
1717
let logger: Logger
1818
let user: User
19-
let event: SubscriptionPurchasedEvent
19+
let event: SubscriptionRenewedEvent
2020
let subscriptionExpirationDate: number
2121
let timestamp: number
2222

@@ -40,7 +40,7 @@ describe('SubscriptionRenewedEventHandler', () => {
4040
timestamp = dayjs.utc().valueOf()
4141
subscriptionExpirationDate = dayjs.utc().valueOf() + 365*1000
4242

43-
event = {} as jest.Mocked<SubscriptionPurchasedEvent>
43+
event = {} as jest.Mocked<SubscriptionRenewedEvent>
4444
event.createdAt = new Date(1)
4545
event.payload = {
4646
userEmail: 'test@test.com',

‎src/Domain/Handler/SubscriptionRenewedEventHandler.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
DomainEventHandlerInterface,
3-
SubscriptionPurchasedEvent,
43
SubscriptionRenewedEvent,
54
} from '@standardnotes/domain-events'
65
import { inject, injectable } from 'inversify'
@@ -20,7 +19,7 @@ implements DomainEventHandlerInterface
2019
@inject(TYPES.Logger) private logger: Logger
2120
) {}
2221
async handle(
23-
event: SubscriptionPurchasedEvent | SubscriptionRenewedEvent
22+
event: SubscriptionRenewedEvent
2423
): Promise<void> {
2524
const user = await this.userRepository.findOneByEmail(
2625
event.payload.userEmail
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import 'reflect-metadata'
2+
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
3+
4+
import { AuthResponseFactoryInterface } from '../../Auth/AuthResponseFactoryInterface'
5+
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
6+
7+
import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
8+
import { User } from '../../User/User'
9+
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
10+
11+
import { ChangeCredentials } from './ChangeCredentials'
12+
13+
describe('ChangeCredentials', () => {
14+
let userRepository: UserRepositoryInterface
15+
let authResponseFactoryResolver: AuthResponseFactoryResolverInterface
16+
let authResponseFactory: AuthResponseFactoryInterface
17+
let domainEventPublisher: DomainEventPublisherInterface
18+
let domainEventFactory: DomainEventFactoryInterface
19+
let user: User
20+
21+
const createUseCase = () => new ChangeCredentials(
22+
userRepository,
23+
authResponseFactoryResolver,
24+
domainEventPublisher,
25+
domainEventFactory,
26+
)
27+
28+
beforeEach(() => {
29+
userRepository = {} as jest.Mocked<UserRepositoryInterface>
30+
userRepository.save = jest.fn()
31+
32+
authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
33+
authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
34+
35+
authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
36+
authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)
37+
38+
user = {} as jest.Mocked<User>
39+
user.encryptedPassword = '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a'
40+
user.uuid = '1-2-3'
41+
user.email = 'test@test.te'
42+
43+
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
44+
domainEventPublisher.publish = jest.fn()
45+
46+
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
47+
domainEventFactory.createUserEmailChangedEvent = jest.fn().mockReturnValue({} as jest.Mocked<UserEmailChangedEvent>)
48+
})
49+
50+
it('should change password', async () => {
51+
expect(await createUseCase().execute({
52+
user,
53+
apiVersion: '20190520',
54+
currentPassword: 'qweqwe123123',
55+
newPassword: 'test234',
56+
pwNonce: 'asdzxc',
57+
updatedWithUserAgent: 'Google Chrome',
58+
kpCreated: '123',
59+
kpOrigination: 'password-change',
60+
})).toEqual({
61+
success: true,
62+
authResponse: {
63+
foo: 'bar',
64+
},
65+
})
66+
67+
expect(userRepository.save).toHaveBeenCalledWith({
68+
encryptedPassword: expect.any(String),
69+
updatedWithUserAgent: 'Google Chrome',
70+
pwNonce: 'asdzxc',
71+
kpCreated: '123',
72+
email: 'test@test.te',
73+
uuid: '1-2-3',
74+
kpOrigination: 'password-change',
75+
})
76+
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
77+
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
78+
})
79+
80+
it('should change email', async () => {
81+
userRepository.findOneByEmail = jest.fn().mockReturnValue(undefined)
82+
83+
expect(await createUseCase().execute({
84+
user,
85+
apiVersion: '20190520',
86+
currentPassword: 'qweqwe123123',
87+
newPassword: 'test234',
88+
newEmail: 'new@test.te',
89+
pwNonce: 'asdzxc',
90+
updatedWithUserAgent: 'Google Chrome',
91+
kpCreated: '123',
92+
kpOrigination: 'password-change',
93+
})).toEqual({
94+
success: true,
95+
authResponse: {
96+
foo: 'bar',
97+
},
98+
})
99+
100+
expect(userRepository.save).toHaveBeenCalledWith({
101+
encryptedPassword: expect.any(String),
102+
email: 'new@test.te',
103+
uuid: '1-2-3',
104+
updatedWithUserAgent: 'Google Chrome',
105+
pwNonce: 'asdzxc',
106+
kpCreated: '123',
107+
kpOrigination: 'password-change',
108+
})
109+
expect(domainEventFactory.createUserEmailChangedEvent).toHaveBeenCalledWith('1-2-3', 'test@test.te', 'new@test.te')
110+
expect(domainEventPublisher.publish).toHaveBeenCalled()
111+
})
112+
113+
it('should not change email if already taken', async () => {
114+
userRepository.findOneByEmail = jest.fn().mockReturnValue({} as jest.Mocked<User>)
115+
116+
expect(await createUseCase().execute({
117+
user,
118+
apiVersion: '20190520',
119+
currentPassword: 'qweqwe123123',
120+
newPassword: 'test234',
121+
newEmail: 'new@test.te',
122+
pwNonce: 'asdzxc',
123+
updatedWithUserAgent: 'Google Chrome',
124+
kpCreated: '123',
125+
kpOrigination: 'password-change',
126+
})).toEqual({
127+
success: false,
128+
errorMessage: 'The email you entered is already taken. Please try again.',
129+
})
130+
131+
expect(userRepository.save).not.toHaveBeenCalled()
132+
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
133+
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
134+
})
135+
136+
it('should not change password if current password is incorrect', async () => {
137+
expect(await createUseCase().execute({
138+
user,
139+
apiVersion: '20190520',
140+
currentPassword: 'test123',
141+
newPassword: 'test234',
142+
pwNonce: 'asdzxc',
143+
updatedWithUserAgent: 'Google Chrome',
144+
})).toEqual({
145+
success: false,
146+
errorMessage: 'The current password you entered is incorrect. Please try again.',
147+
})
148+
149+
expect(userRepository.save).not.toHaveBeenCalled()
150+
})
151+
152+
it('should update protocol version while changing password', async () => {
153+
expect(await createUseCase().execute({
154+
user,
155+
apiVersion: '20190520',
156+
currentPassword: 'qweqwe123123',
157+
newPassword: 'test234',
158+
pwNonce: 'asdzxc',
159+
updatedWithUserAgent: 'Google Chrome',
160+
protocolVersion: '004',
161+
})).toEqual({
162+
success: true,
163+
authResponse: {
164+
foo: 'bar',
165+
},
166+
})
167+
168+
expect(userRepository.save).toHaveBeenCalledWith({
169+
encryptedPassword: expect.any(String),
170+
updatedWithUserAgent: 'Google Chrome',
171+
pwNonce: 'asdzxc',
172+
version: '004',
173+
email: 'test@test.te',
174+
uuid: '1-2-3',
175+
})
176+
})
177+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as bcrypt from 'bcryptjs'
2+
import { inject, injectable } from 'inversify'
3+
import TYPES from '../../../Bootstrap/Types'
4+
import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
5+
6+
import { User } from '../../User/User'
7+
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
8+
import { ChangeCredentialsDTO } from './ChangeCredentialsDTO'
9+
import { ChangeCredentialsResponse } from './ChangeCredentialsResponse'
10+
import { UseCaseInterface } from '../UseCaseInterface'
11+
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
12+
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
13+
14+
@injectable()
15+
export class ChangeCredentials implements UseCaseInterface {
16+
constructor(
17+
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
18+
@inject(TYPES.AuthResponseFactoryResolver) private authResponseFactoryResolver: AuthResponseFactoryResolverInterface,
19+
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
20+
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
21+
) {
22+
}
23+
24+
async execute(dto: ChangeCredentialsDTO): Promise<ChangeCredentialsResponse> {
25+
if (!await bcrypt.compare(dto.currentPassword, dto.user.encryptedPassword)) {
26+
return {
27+
success: false,
28+
errorMessage: 'The current password you entered is incorrect. Please try again.',
29+
}
30+
}
31+
32+
dto.user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST)
33+
34+
let userEmailChangedEvent: UserEmailChangedEvent | undefined = undefined
35+
if (dto.newEmail !== undefined) {
36+
const existingUser = await this.userRepository.findOneByEmail(dto.newEmail)
37+
if (existingUser !== undefined) {
38+
return {
39+
success: false,
40+
errorMessage: 'The email you entered is already taken. Please try again.',
41+
}
42+
}
43+
44+
userEmailChangedEvent = this.domainEventFactory.createUserEmailChangedEvent(dto.user.uuid, dto.user.email, dto.newEmail)
45+
46+
dto.user.email = dto.newEmail
47+
}
48+
49+
dto.user.updatedWithUserAgent = dto.updatedWithUserAgent
50+
dto.user.pwNonce = dto.pwNonce
51+
if (dto.protocolVersion) {
52+
dto.user.version = dto.protocolVersion
53+
}
54+
if (dto.kpCreated) {
55+
dto.user.kpCreated = dto.kpCreated
56+
}
57+
if (dto.kpOrigination) {
58+
dto.user.kpOrigination = dto.kpOrigination
59+
}
60+
61+
const updatedUser = await this.userRepository.save(dto.user)
62+
63+
if (userEmailChangedEvent !== undefined) {
64+
await this.domainEventPublisher.publish(userEmailChangedEvent)
65+
}
66+
67+
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
68+
69+
return {
70+
success: true,
71+
authResponse: await authResponseFactory.createResponse(updatedUser, dto.apiVersion, dto.updatedWithUserAgent),
72+
}
73+
}
74+
}

‎src/Domain/UseCase/ChangePasswordDTO.ts ‎src/Domain/UseCase/ChangeCredentials/ChangeCredentialsDTO.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { User } from '../User/User'
1+
import { User } from '../../User/User'
22

3-
export type ChangePasswordDTO = {
3+
export type ChangeCredentialsDTO = {
44
user: User
55
apiVersion: string
66
currentPassword: string
77
newPassword: string
8+
newEmail?: string
89
pwNonce: string
910
updatedWithUserAgent: string
1011
protocolVersion?: string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
2+
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
3+
4+
export type ChangeCredentialsResponse = {
5+
success: boolean
6+
authResponse?: AuthResponse20161215 | AuthResponse20200115
7+
errorMessage?: string
8+
}

‎src/Domain/UseCase/ChangePassword.spec.ts

-97
This file was deleted.

‎src/Domain/UseCase/ChangePassword.ts

-50
This file was deleted.

‎src/Domain/UseCase/ChangePasswordResponse.ts

-8
This file was deleted.

‎src/Domain/UseCase/GetUserFeatures/GetUserFeatures.spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import 'reflect-metadata'
2-
import { Feature } from '@standardnotes/features'
2+
import { FeatureDescription } from '@standardnotes/features'
33
import { GetUserFeatures } from './GetUserFeatures'
44
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
55
import { User } from '../../User/User'
@@ -8,7 +8,7 @@ import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface'
88
describe('GetUserFeatures', () => {
99
let user: User
1010
let userRepository: UserRepositoryInterface
11-
let feature1: Feature
11+
let feature1: FeatureDescription
1212
let featureService: FeatureServiceInterface
1313

1414
const createUseCase = () => new GetUserFeatures(userRepository, featureService)
@@ -18,7 +18,7 @@ describe('GetUserFeatures', () => {
1818
userRepository = {} as jest.Mocked<UserRepositoryInterface>
1919
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
2020

21-
feature1 = { name: 'foobar' } as jest.Mocked<Feature>
21+
feature1 = { name: 'foobar' } as jest.Mocked<FeatureDescription>
2222
featureService = {} as jest.Mocked<FeatureServiceInterface>
2323
featureService.getFeaturesForUser = jest.fn().mockReturnValue([feature1])
2424
})

‎src/Domain/UseCase/GetUserFeatures/GetUserFeaturesResponse.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Feature } from '@standardnotes/features'
1+
import { FeatureDescription } from '@standardnotes/features'
22

33
export type GetUserFeaturesResponse = {
44
success: true,
55
userUuid: string,
6-
features: Feature[]
6+
features: FeatureDescription[]
77
} | {
88
success: false,
99
error: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import 'reflect-metadata'
2+
import { GetUserSubscription } from './GetUserSubscription'
3+
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
4+
import { User } from '../../User/User'
5+
import { UserSubscriptionRepositoryInterface } from '../../User/UserSubscriptionRepositoryInterface'
6+
import { UserSubscription } from '../../User/UserSubscription'
7+
import { SubscriptionName } from '@standardnotes/auth'
8+
9+
describe('GetUserSubscription', () => {
10+
let user: User
11+
let userSubscription: UserSubscription
12+
let userRepository: UserRepositoryInterface
13+
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
14+
15+
const createUseCase = () => new GetUserSubscription(userRepository, userSubscriptionRepository)
16+
17+
beforeEach(() => {
18+
user = {} as jest.Mocked<User>
19+
userRepository = {} as jest.Mocked<UserRepositoryInterface>
20+
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
21+
22+
userSubscription = {
23+
planName: SubscriptionName.ProPlan,
24+
} as jest.Mocked<UserSubscription>
25+
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
26+
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(userSubscription)
27+
})
28+
29+
it('should fail if a user is not found', async () => {
30+
userRepository.findOneByUuid = jest.fn().mockReturnValue(undefined)
31+
32+
expect(await createUseCase().execute({ userUuid: 'user-1-1-1' })).toEqual({
33+
success: false,
34+
error: {
35+
message: 'User user-1-1-1 not found.',
36+
},
37+
})
38+
})
39+
40+
it('should return user subscription', async () => {
41+
expect(await createUseCase().execute({ userUuid: 'user-1-1-1' })).toEqual({
42+
success: true,
43+
userUuid: 'user-1-1-1',
44+
subscription: {
45+
planName: SubscriptionName.ProPlan,
46+
},
47+
})
48+
})
49+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { UseCaseInterface } from '../UseCaseInterface'
2+
import { inject, injectable } from 'inversify'
3+
import TYPES from '../../../Bootstrap/Types'
4+
import { GetUserSubscriptionDto } from './GetUserSubscriptionDto'
5+
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
6+
import { GetUserSubscriptionResponse } from './GetUserSubscriptionResponse'
7+
import { UserSubscriptionRepositoryInterface } from '../../User/UserSubscriptionRepositoryInterface'
8+
9+
@injectable()
10+
export class GetUserSubscription implements UseCaseInterface {
11+
constructor(
12+
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
13+
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
14+
) {
15+
}
16+
17+
async execute(dto: GetUserSubscriptionDto): Promise<GetUserSubscriptionResponse> {
18+
const { userUuid } = dto
19+
20+
const user = await this.userRepository.findOneByUuid(userUuid)
21+
22+
if (user === undefined) {
23+
return {
24+
success: false,
25+
error: {
26+
message: `User ${userUuid} not found.`,
27+
},
28+
}
29+
}
30+
31+
const userSubscription = await this.userSubscriptionRepository.findOneByUserUuid(userUuid)
32+
33+
return {
34+
success: true,
35+
userUuid,
36+
subscription: userSubscription,
37+
}
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Uuid } from '@standardnotes/common'
2+
3+
export type GetUserSubscriptionDto = {
4+
userUuid: Uuid,
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { UserSubscription } from '../../User/UserSubscription'
2+
3+
export type GetUserSubscriptionResponse = {
4+
success: true,
5+
userUuid: string,
6+
subscription?: UserSubscription,
7+
} | {
8+
success: false,
9+
error: {
10+
message: string
11+
}
12+
}

‎src/Domain/UseCase/UpdateUser.spec.ts

-39
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,16 @@ import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterfa
66
import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface'
77

88
import { UpdateUser } from './UpdateUser'
9-
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
10-
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
119

1210
describe('UpdateUser', () => {
1311
let userRepository: UserRepositoryInterface
1412
let authResponseFactoryResolver: AuthResponseFactoryResolverInterface
1513
let authResponseFactory: AuthResponseFactoryInterface
16-
let domainEventPublisher: DomainEventPublisherInterface
17-
let domainEventFactory: DomainEventFactoryInterface
1814
let user: User
1915

2016
const createUseCase = () => new UpdateUser(
2117
userRepository,
2218
authResponseFactoryResolver,
23-
domainEventPublisher,
24-
domainEventFactory
2519
)
2620

2721
beforeEach(() => {
@@ -35,12 +29,6 @@ describe('UpdateUser', () => {
3529
authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
3630
authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)
3731

38-
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
39-
domainEventPublisher.publish = jest.fn()
40-
41-
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
42-
domainEventFactory.createUserEmailChangedEvent = jest.fn().mockReturnValue({} as jest.Mocked<UserEmailChangedEvent>)
43-
4432
user = {} as jest.Mocked<User>
4533
user.uuid = '123'
4634
user.email = 'test@test.te'
@@ -67,32 +55,5 @@ describe('UpdateUser', () => {
6755
uuid: '123',
6856
version: '004',
6957
})
70-
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
71-
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
72-
})
73-
74-
it('should fail to change user email if a user already exists with that email', async () => {
75-
userRepository.findOneByEmail = jest.fn().mockReturnValue({} as jest.Mocked<User>)
76-
77-
expect(await createUseCase().execute({
78-
user,
79-
updatedWithUserAgent: 'Mozilla',
80-
apiVersion: '20190520',
81-
version: '004',
82-
email: 'test2@test.te',
83-
})).toEqual({ success: false })
84-
})
85-
86-
it('should change user email', async () => {
87-
expect(await createUseCase().execute({
88-
user,
89-
updatedWithUserAgent: 'Mozilla',
90-
apiVersion: '20190520',
91-
version: '004',
92-
email: 'test2@test.te',
93-
})).toEqual({ success: true, authResponse: { foo: 'bar' } })
94-
95-
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1)
96-
expect(domainEventFactory.createUserEmailChangedEvent).toHaveBeenLastCalledWith('123', 'test@test.te', 'test2@test.te')
9758
})
9859
})

‎src/Domain/UseCase/UpdateUser.ts

-20
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
21
import { inject, injectable } from 'inversify'
32
import TYPES from '../../Bootstrap/Types'
43
import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface'
5-
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
64
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
75
import { UpdateUserDTO } from './UpdateUserDTO'
86
import { UpdateUserResponse } from './UpdateUserResponse'
@@ -13,8 +11,6 @@ export class UpdateUser implements UseCaseInterface {
1311
constructor (
1412
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
1513
@inject(TYPES.AuthResponseFactoryResolver) private authResponseFactoryResolver: AuthResponseFactoryResolverInterface,
16-
@inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
17-
@inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
1814
) {
1915
}
2016

@@ -27,26 +23,10 @@ export class UpdateUser implements UseCaseInterface {
2723
&& delete updateFields[key]
2824
)
2925

30-
let UserEmailChangedEvent: UserEmailChangedEvent | undefined = undefined
31-
if ('email' in updateFields) {
32-
const existingUser = await this.userRepository.findOneByEmail(updateFields.email as string)
33-
if (existingUser !== undefined) {
34-
return {
35-
success: false,
36-
}
37-
}
38-
39-
UserEmailChangedEvent = this.domainEventFactory.createUserEmailChangedEvent(user.uuid, user.email, updateFields.email as string)
40-
}
41-
4226
Object.assign(user, updateFields)
4327

4428
await this.userRepository.save(user)
4529

46-
if (UserEmailChangedEvent) {
47-
await this.domainEventPublisher.publish(UserEmailChangedEvent)
48-
}
49-
5030
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion)
5131

5232
return {

‎src/Domain/User/UserSubscription.ts

+8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export class UserSubscription {
3232
@Index('updated_at')
3333
updatedAt: number
3434

35+
@Column({
36+
type: 'tinyint',
37+
width: 1,
38+
nullable: false,
39+
default: 0,
40+
})
41+
cancelled: boolean
42+
3543
@ManyToOne(
3644
/* istanbul ignore next */
3745
() => User,
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { UserSubscription } from './UserSubscription'
22

33
export interface UserSubscriptionRepositoryInterface {
4+
findOneByUserUuid(userUuid: string): Promise<UserSubscription | undefined>
45
updateEndsAtByNameAndUserUuid(name: string, userUuid: string, endsAt: number, updatedAt: number): Promise<void>
6+
updateCancelled(name: string, userUuid: string, cancelled: boolean, updatedAt: number): Promise<void>
57
save(subscription: UserSubscription): Promise<UserSubscription>
68
}
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,102 @@
1+
import { SubscriptionName } from '@standardnotes/auth'
12
import 'reflect-metadata'
23

3-
import { UpdateQueryBuilder } from 'typeorm'
4+
import { SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm'
45
import { UserSubscription } from '../../Domain/User/UserSubscription'
56

67
import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository'
78

89
describe('MySQLUserSubscriptionRepository', () => {
910
let repository: MySQLUserSubscriptionRepository
10-
let queryBuilder: UpdateQueryBuilder<UserSubscription>
11+
let selectQueryBuilder: SelectQueryBuilder<UserSubscription>
12+
let updateQueryBuilder: UpdateQueryBuilder<UserSubscription>
13+
let subscription: UserSubscription
1114

1215
beforeEach(() => {
13-
queryBuilder = {} as jest.Mocked<UpdateQueryBuilder<UserSubscription>>
16+
selectQueryBuilder = {} as jest.Mocked<SelectQueryBuilder<UserSubscription>>
17+
updateQueryBuilder = {} as jest.Mocked<UpdateQueryBuilder<UserSubscription>>
18+
19+
subscription = {
20+
planName: SubscriptionName.ProPlan,
21+
} as jest.Mocked<UserSubscription>
1422

1523
repository = new MySQLUserSubscriptionRepository()
1624
jest.spyOn(repository, 'createQueryBuilder')
17-
repository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
25+
})
26+
27+
it('should find one by user uuid', async () => {
28+
repository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder)
29+
30+
selectQueryBuilder.where = jest.fn().mockReturnThis()
31+
selectQueryBuilder.orderBy = jest.fn().mockReturnThis()
32+
selectQueryBuilder.getOne = jest.fn().mockReturnValue(subscription)
33+
34+
const result = await repository.findOneByUserUuid('123')
35+
36+
expect(selectQueryBuilder.where).toHaveBeenCalledWith(
37+
'user_uuid = :user_uuid',
38+
{
39+
user_uuid: '123',
40+
},
41+
)
42+
expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith(
43+
'ends_at', 'DESC'
44+
)
45+
expect(selectQueryBuilder.getOne).toHaveBeenCalled()
46+
expect(result).toEqual(subscription)
1847
})
1948

2049
it('should update ends at by name and user uuid', async () => {
21-
repository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
50+
repository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder)
2251

23-
queryBuilder.update = jest.fn().mockReturnThis()
24-
queryBuilder.set = jest.fn().mockReturnThis()
25-
queryBuilder.where = jest.fn().mockReturnThis()
26-
queryBuilder.execute = jest.fn()
52+
updateQueryBuilder.update = jest.fn().mockReturnThis()
53+
updateQueryBuilder.set = jest.fn().mockReturnThis()
54+
updateQueryBuilder.where = jest.fn().mockReturnThis()
55+
updateQueryBuilder.execute = jest.fn()
2756

2857
await repository.updateEndsAtByNameAndUserUuid('test', '123', 1000, 1000)
2958

30-
expect(queryBuilder.update).toHaveBeenCalled()
31-
expect(queryBuilder.set).toHaveBeenCalledWith(
59+
expect(updateQueryBuilder.update).toHaveBeenCalled()
60+
expect(updateQueryBuilder.set).toHaveBeenCalledWith(
3261
{
3362
updatedAt: expect.any(Number),
3463
endsAt: 1000,
3564
}
3665
)
37-
expect(queryBuilder.where).toHaveBeenCalledWith(
66+
expect(updateQueryBuilder.where).toHaveBeenCalledWith(
67+
'plan_name = :plan_name AND user_uuid = :user_uuid',
68+
{
69+
plan_name: 'test',
70+
user_uuid: '123',
71+
}
72+
)
73+
expect(updateQueryBuilder.execute).toHaveBeenCalled()
74+
})
75+
76+
it('should update cancelled by name and user uuid', async () => {
77+
repository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder)
78+
79+
updateQueryBuilder.update = jest.fn().mockReturnThis()
80+
updateQueryBuilder.set = jest.fn().mockReturnThis()
81+
updateQueryBuilder.where = jest.fn().mockReturnThis()
82+
updateQueryBuilder.execute = jest.fn()
83+
84+
await repository.updateCancelled('test', '123', true, 1000)
85+
86+
expect(updateQueryBuilder.update).toHaveBeenCalled()
87+
expect(updateQueryBuilder.set).toHaveBeenCalledWith(
88+
{
89+
updatedAt: expect.any(Number),
90+
cancelled: true,
91+
}
92+
)
93+
expect(updateQueryBuilder.where).toHaveBeenCalledWith(
3894
'plan_name = :plan_name AND user_uuid = :user_uuid',
3995
{
4096
plan_name: 'test',
4197
user_uuid: '123',
4298
}
4399
)
44-
expect(queryBuilder.execute).toHaveBeenCalled()
100+
expect(updateQueryBuilder.execute).toHaveBeenCalled()
45101
})
46102
})

‎src/Infra/MySQL/MySQLUserSubscriptionRepository.ts

+29
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import { UserSubscriptionRepositoryInterface } from '../../Domain/User/UserSubsc
77
@injectable()
88
@EntityRepository(UserSubscription)
99
export class MySQLUserSubscriptionRepository extends Repository<UserSubscription> implements UserSubscriptionRepositoryInterface {
10+
async findOneByUserUuid(userUuid: string): Promise<UserSubscription | undefined> {
11+
return await this.createQueryBuilder()
12+
.where(
13+
'user_uuid = :user_uuid',
14+
{
15+
user_uuid: userUuid,
16+
}
17+
)
18+
.orderBy('ends_at', 'DESC')
19+
.getOne()
20+
}
21+
1022
async updateEndsAtByNameAndUserUuid(name: string, userUuid: string, endsAt: number, updatedAt: number): Promise<void> {
1123
await this.createQueryBuilder()
1224
.update()
@@ -23,4 +35,21 @@ export class MySQLUserSubscriptionRepository extends Repository<UserSubscription
2335
)
2436
.execute()
2537
}
38+
39+
async updateCancelled(name: string, userUuid: string, cancelled: boolean, updatedAt: number): Promise<void> {
40+
await this.createQueryBuilder()
41+
.update()
42+
.set({
43+
cancelled,
44+
updatedAt,
45+
})
46+
.where(
47+
'plan_name = :plan_name AND user_uuid = :user_uuid',
48+
{
49+
plan_name: name,
50+
user_uuid: userUuid,
51+
}
52+
)
53+
.execute()
54+
}
2655
}

‎yarn.lock

+5-5
Original file line numberDiff line numberDiff line change
@@ -715,12 +715,12 @@
715715
dependencies:
716716
"@standardnotes/auth" "^3.7.0"
717717

718-
"@standardnotes/features@1.3.0":
719-
version "1.3.0"
720-
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.3.0.tgz#a7894dfd6b00aa4917d613e7b831d97f7e5154f2"
721-
integrity sha512-H11Jv3uPHmGMC9MmRAMN75ozTOeFS56Fif4egUShxj4mfNE6cRx85FFAqPf3URKukMHnPR1Rf+B24ezrgcnXIg==
718+
"@standardnotes/features@1.6.2":
719+
version "1.6.2"
720+
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.6.2.tgz#98c5998426d9f93e06c2846c5bc7b6aef8d31063"
721+
integrity sha512-s/rqRyG7mrrgxJOzckPSYlB68wsRpM9jlFwDE+7zQO5/xKh+37ueWfy3RoqOgkKLey6lMpnTurofIJCvqLM3dQ==
722722
dependencies:
723-
"@standardnotes/common" "^1.0.0"
723+
"@standardnotes/common" "^1.1.0"
724724

725725
"@standardnotes/settings@1.1.0":
726726
version "1.1.0"

0 commit comments

Comments
 (0)
Please sign in to comment.