Skip to content

Commit 039ee1b

Browse files
committed
feat(accounts): AccessDeniedException
Signed-off-by: Lexus Drumgold <[email protected]>
1 parent 0669ca3 commit 039ee1b

File tree

13 files changed

+250
-47
lines changed

13 files changed

+250
-47
lines changed

src/__snapshots__/app.e2e.snap

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = `
9494
}
9595
}
9696
},
97+
"403": {
98+
"content": {
99+
"application/json": {
100+
"schema": {
101+
"$ref": "#/components/schemas/AccessDeniedException"
102+
}
103+
}
104+
}
105+
},
97106
"404": {
98107
"content": {
99108
"application/json": {
@@ -182,6 +191,38 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = `
182191
}
183192
},
184193
"schemas": {
194+
"AccessDeniedException": {
195+
"type": "object",
196+
"properties": {
197+
"code": {
198+
"type": "number",
199+
"description": "http response status code",
200+
"enum": [
201+
403
202+
]
203+
},
204+
"id": {
205+
"type": "string",
206+
"description": "unique id representing the exception",
207+
"enum": [
208+
"accounts/access-denied"
209+
]
210+
},
211+
"message": {
212+
"type": "string",
213+
"description": "human-readable description of the exception"
214+
},
215+
"reason": {
216+
"type": "null"
217+
}
218+
},
219+
"required": [
220+
"code",
221+
"id",
222+
"message",
223+
"reason"
224+
]
225+
},
185226
"AccountCreatedPayload": {
186227
"type": "object",
187228
"properties": {

src/errors/enums/exception-id.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* @enum {Lowercase<string>}
1010
*/
1111
enum ExceptionId {
12+
ACCESS_DENIED = 'accounts/access-denied',
1213
EMAIL_CONFLICT = 'accounts/email-conflict',
1314
INTERNAL_SERVER_ERROR = 'sneusers/internal-error',
1415
INVALID_CREDENTIAL = 'accounts/invalid-credential',

src/errors/models/validation.exception.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class ValidationException extends Exception {
3333
*
3434
* @public
3535
* @instance
36-
* @member {ExceptionId.VALIDATION_FAILURE} code
36+
* @member {ExceptionId.VALIDATION_FAILURE} id
3737
*/
3838
@ApiProperty({ enum: [ExceptionId.VALIDATION_FAILURE] })
3939
declare public id: (typeof ExceptionId)['VALIDATION_FAILURE']

src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -301,24 +301,25 @@ describe('e2e:accounts/AccountsModule', () => {
301301
})
302302

303303
describe('401 (UNAUTHORIZED)', () => {
304-
let account1: Account
305-
let account2: Account
304+
let account: Account
306305

307306
afterAll(async () => {
308307
await seeder.down()
309308
})
310309

311310
beforeAll(async () => {
312-
await seeder.up(2)
313-
account1 = new Account(seeder.seeds[0]!)
314-
account2 = new Account(seeder.seeds[1]!)
311+
await seeder.up(1)
312+
account = new Account(seeder.seeds[0]!)
315313
})
316314

317-
it('authentication failure (no auth token)', async () => {
315+
it('authentication failure (invalid token)', async () => {
318316
// Arrange
319317
const request: InjectOptions = {
318+
headers: {
319+
authorization: `bearer ${faker.internet.jwt()}`
320+
},
320321
method,
321-
url: routes.ACCOUNTS + routes.APP + account1.uid
322+
url: routes.ACCOUNTS + routes.APP + account.uid
322323
}
323324

324325
// Act
@@ -334,14 +335,11 @@ describe('e2e:accounts/AccountsModule', () => {
334335
expect(payload).to.have.property('reason', null)
335336
})
336337

337-
it('authentication failure (token mismatch)', async () => {
338+
it('authentication failure (missing token)', async () => {
338339
// Arrange
339340
const request: InjectOptions = {
340-
headers: {
341-
authorization: `bearer ${await auth.accessToken(account1)}`
342-
},
343341
method,
344-
url: routes.ACCOUNTS + routes.APP + account2.uid
342+
url: routes.ACCOUNTS + routes.APP + account.uid
345343
}
346344

347345
// Act
@@ -358,6 +356,43 @@ describe('e2e:accounts/AccountsModule', () => {
358356
})
359357
})
360358

359+
describe('403 (FORBIDDEN)', () => {
360+
let account1: Account
361+
let account2: Account
362+
let result: Response
363+
364+
afterAll(async () => {
365+
await seeder.down()
366+
})
367+
368+
beforeAll(async () => {
369+
await seeder.up(2)
370+
account1 = new Account(seeder.seeds[0]!)
371+
account2 = new Account(seeder.seeds[1]!)
372+
373+
result = await app.inject({
374+
headers: {
375+
authorization: `bearer ${await auth.accessToken(account2)}`
376+
},
377+
method,
378+
url: routes.ACCOUNTS + routes.APP + account1.uid
379+
})
380+
})
381+
382+
it('authentication failure (uid mismatch)', async () => {
383+
// Act
384+
const payload = result.json()
385+
386+
// Expect
387+
expect(result).to.be.json.with.status(HttpStatus.FORBIDDEN)
388+
expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS)
389+
expect(payload).to.have.property('code', HttpStatus.FORBIDDEN)
390+
expect(payload).to.have.property('id', ExceptionId.ACCESS_DENIED)
391+
expect(payload).to.have.property('message').be.a('string').and.not.empty
392+
expect(payload).to.have.property('reason', null)
393+
})
394+
})
395+
361396
describe('404 (NOT FOUND)', () => {
362397
let account: Account
363398
let url: string
@@ -367,9 +402,14 @@ describe('e2e:accounts/AccountsModule', () => {
367402
url = routes.ACCOUNTS + routes.APP + account.uid
368403
})
369404

370-
it('fail on missing account (no auth token)', async () => {
405+
it('fail on missing account (authenticated)', async () => {
406+
// Arrange
407+
const headers: IncomingHttpHeaders = {
408+
authorization: `bearer ${await auth.accessToken(account)}`
409+
}
410+
371411
// Act
372-
const result = await app.inject({ method, url })
412+
const result = await app.inject({ headers, method, url })
373413
const payload = result.json()
374414

375415
// Expect
@@ -382,14 +422,9 @@ describe('e2e:accounts/AccountsModule', () => {
382422
expect(payload).to.have.nested.property('reason.uid', account.uid)
383423
})
384424

385-
it('fail on missing account (with auth token)', async () => {
386-
// Arrange
387-
const headers: IncomingHttpHeaders = {
388-
authorization: `bearer ${await auth.accessToken(account)}`
389-
}
390-
425+
it('fail on missing account (unauthenticated)', async () => {
391426
// Act
392-
const result = await app.inject({ headers, method, url })
427+
const result = await app.inject({ method, url })
393428
const payload = result.json()
394429

395430
// Expect

src/subdomains/accounts/controllers/accounts.controller.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import UnhandledExceptionFilter from '#filters/unhandled.filter'
2121
import TransformPipe from '#pipes/transform.pipe'
2222
import type { Account } from '@flex-development/sneusers/accounts'
2323
import {
24+
AccessDeniedException,
2425
EmailConflictException,
2526
InvalidCredentialException,
2627
MissingAccountException
@@ -49,6 +50,7 @@ import {
4950
ApiBearerAuth,
5051
ApiConflictResponse,
5152
ApiCreatedResponse,
53+
ApiForbiddenResponse,
5254
ApiHeader,
5355
ApiInternalServerErrorResponse,
5456
ApiNoContentResponse,
@@ -140,6 +142,7 @@ class AccountsController {
140142
@ApiBearerAuth(AuthStrategy.JWT)
141143
@ApiNoContentResponse()
142144
@ApiUnauthorizedResponse({ type: InvalidCredentialException })
145+
@ApiForbiddenResponse({ type: AccessDeniedException })
143146
@ApiNotFoundResponse({ type: MissingAccountException })
144147
public async delete(@Param() params: DeleteAccountCommand): Promise<null> {
145148
ok(params instanceof DeleteAccountCommand, 'expected a command')
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @file Unit Tests - AccessDeniedException
3+
* @module sneusers/accounts/errors/tests/unit/AccessDeniedException
4+
*/
5+
6+
import TestSubject from '#accounts/errors/access-denied.exception'
7+
import ExceptionCode from '#errors/enums/exception-code'
8+
import ExceptionId from '#errors/enums/exception-id'
9+
import Exception from '#errors/models/base.exception'
10+
11+
describe('unit:accounts/errors/AccessDeniedException', () => {
12+
describe('constructor', () => {
13+
let subject: TestSubject
14+
15+
beforeAll(() => {
16+
subject = new TestSubject()
17+
})
18+
19+
it('should be instanceof Exception', () => {
20+
expect(subject).to.be.instanceof(Exception)
21+
})
22+
23+
it('should set #cause', () => {
24+
expect(subject).to.have.property('cause', null)
25+
})
26+
27+
it('should set #code', () => {
28+
expect(subject).to.have.property('code', ExceptionCode.FORBIDDEN)
29+
})
30+
31+
it('should set #id', () => {
32+
expect(subject).to.have.property('id', ExceptionId.ACCESS_DENIED)
33+
})
34+
35+
it('should set #message', () => {
36+
expect(subject).to.have.property('message', 'Access denied')
37+
})
38+
39+
it('should set #name', () => {
40+
expect(subject).to.have.property('name', TestSubject.name)
41+
})
42+
43+
it('should set #reason', () => {
44+
expect(subject).to.have.property('reason', null)
45+
})
46+
47+
it('should set #stack', () => {
48+
expect(subject).to.have.property('stack').be.a('string').that.is.not.empty
49+
})
50+
})
51+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @file Errors - AccessDeniedException
3+
* @module sneusers/accounts/errors/AccessDeniedException
4+
*/
5+
6+
import {
7+
Exception,
8+
ExceptionCode,
9+
ExceptionId
10+
} from '@flex-development/sneusers/errors'
11+
import { ApiProperty, ApiSchema } from '@nestjs/swagger'
12+
13+
/**
14+
* An access denied exception.
15+
*
16+
* @class
17+
* @extends {Exception}
18+
*/
19+
@ApiSchema()
20+
class AccessDeniedException extends Exception {
21+
/**
22+
* HTTP response status code.
23+
*
24+
* @public
25+
* @instance
26+
* @member {ExceptionCode.FORBIDDEN} code
27+
*/
28+
@ApiProperty({ enum: [ExceptionCode.FORBIDDEN] })
29+
declare public code: (typeof ExceptionCode)['FORBIDDEN']
30+
31+
/**
32+
* Unique id representing the exception.
33+
*
34+
* @public
35+
* @instance
36+
* @member {ExceptionId.ACCESS_DENIED} id
37+
*/
38+
@ApiProperty({ enum: [ExceptionId.ACCESS_DENIED] })
39+
declare public id: (typeof ExceptionId)['ACCESS_DENIED']
40+
41+
/**
42+
* The reason for the exception.
43+
*
44+
* @public
45+
* @instance
46+
* @member {null} reason
47+
*/
48+
@ApiProperty({ type: 'null' })
49+
declare public reason: null
50+
51+
/**
52+
* Create a new access denied exception.
53+
*/
54+
constructor() {
55+
super({
56+
code: ExceptionCode.FORBIDDEN,
57+
id: ExceptionId.ACCESS_DENIED,
58+
message: 'Access denied',
59+
reason: null
60+
})
61+
62+
this.name = 'AccessDeniedException'
63+
}
64+
}
65+
66+
export default AccessDeniedException

src/subdomains/accounts/errors/email-conflict.exception.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class EmailConflictException extends Exception {
3434
*
3535
* @public
3636
* @instance
37-
* @member {ExceptionId.EMAIL_CONFLICT} code
37+
* @member {ExceptionId.EMAIL_CONFLICT} id
3838
*/
3939
@ApiProperty({ enum: [ExceptionId.EMAIL_CONFLICT] })
4040
declare public id: (typeof ExceptionId)['EMAIL_CONFLICT']

src/subdomains/accounts/errors/index.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* @module sneusers/accounts/errors
44
*/
55

6+
export {
7+
default as AccessDeniedException
8+
} from '#accounts/errors/access-denied.exception'
69
export {
710
default as EmailConflictException
811
} from '#accounts/errors/email-conflict.exception'

src/subdomains/accounts/errors/invalid-credential.exception.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class InvalidCredentialException extends Exception {
3333
*
3434
* @public
3535
* @instance
36-
* @member {ExceptionId.INVALID_CREDENTIAL} code
36+
* @member {ExceptionId.INVALID_CREDENTIAL} id
3737
*/
3838
@ApiProperty({ enum: [ExceptionId.INVALID_CREDENTIAL] })
3939
declare public id: (typeof ExceptionId)['INVALID_CREDENTIAL']

0 commit comments

Comments
 (0)