diff --git a/.adonisjs/client/manifest.d.ts b/.adonisjs/client/manifest.d.ts index 9fc3356..caa1e21 100644 --- a/.adonisjs/client/manifest.d.ts +++ b/.adonisjs/client/manifest.d.ts @@ -6,4 +6,6 @@ /// /// /// +/// /// +/// diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index 298b217..c7d5de1 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -6,29 +6,161 @@ import type { ApiDefinition } from './tree.d.ts' const placeholder: any = {} const routes = { - 'auth.new_account.store': { + 'health.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/health', + tokens: [{"old":"/api/v1/health","type":0,"val":"api","end":""},{"old":"/api/v1/health","type":0,"val":"v1","end":""},{"old":"/api/v1/health","type":0,"val":"health","end":""}], + types: placeholder as Registry['health.index']['types'], + }, + 'auth.register': { methods: ["POST"], - pattern: '/api/v1/auth/signup', - tokens: [{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}], - types: placeholder as Registry['auth.new_account.store']['types'], + pattern: '/api/v1/auth/register', + tokens: [{"old":"/api/v1/auth/register","type":0,"val":"api","end":""},{"old":"/api/v1/auth/register","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/register","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/register","type":0,"val":"register","end":""}], + types: placeholder as Registry['auth.register']['types'], }, - 'auth.access_tokens.store': { + 'auth.activate': { + methods: ["POST"], + pattern: '/api/v1/auth/activate', + tokens: [{"old":"/api/v1/auth/activate","type":0,"val":"api","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/activate","type":0,"val":"activate","end":""}], + types: placeholder as Registry['auth.activate']['types'], + }, + 'auth.login': { methods: ["POST"], pattern: '/api/v1/auth/login', tokens: [{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}], - types: placeholder as Registry['auth.access_tokens.store']['types'], + types: placeholder as Registry['auth.login']['types'], + }, + 'auth.logout': { + methods: ["POST"], + pattern: '/api/v1/auth/logout', + tokens: [{"old":"/api/v1/auth/logout","type":0,"val":"api","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/logout","type":0,"val":"logout","end":""}], + types: placeholder as Registry['auth.logout']['types'], + }, + 'auth.refresh': { + methods: ["POST"], + pattern: '/api/v1/auth/refresh', + tokens: [{"old":"/api/v1/auth/refresh","type":0,"val":"api","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/refresh","type":0,"val":"refresh","end":""}], + types: placeholder as Registry['auth.refresh']['types'], + }, + 'auth.forgot_password': { + methods: ["POST"], + pattern: '/api/v1/auth/forgot-password', + tokens: [{"old":"/api/v1/auth/forgot-password","type":0,"val":"api","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/forgot-password","type":0,"val":"forgot-password","end":""}], + types: placeholder as Registry['auth.forgot_password']['types'], + }, + 'auth.reset_password': { + methods: ["POST"], + pattern: '/api/v1/auth/reset-password', + tokens: [{"old":"/api/v1/auth/reset-password","type":0,"val":"api","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/reset-password","type":0,"val":"reset-password","end":""}], + types: placeholder as Registry['auth.reset_password']['types'], + }, + 'auth.me': { + methods: ["GET","HEAD"], + pattern: '/api/v1/auth/me', + tokens: [{"old":"/api/v1/auth/me","type":0,"val":"api","end":""},{"old":"/api/v1/auth/me","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/me","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/me","type":0,"val":"me","end":""}], + types: placeholder as Registry['auth.me']['types'], + }, + 'users.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/users', + tokens: [{"old":"/api/v1/users","type":0,"val":"api","end":""},{"old":"/api/v1/users","type":0,"val":"v1","end":""},{"old":"/api/v1/users","type":0,"val":"users","end":""}], + types: placeholder as Registry['users.index']['types'], + }, + 'users.show': { + methods: ["GET","HEAD"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.show']['types'], }, - 'profile.profile.show': { + 'users.update': { + methods: ["PUT"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.update']['types'], + }, + 'users.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/users/:id', + tokens: [{"old":"/api/v1/users/:id","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['users.destroy']['types'], + }, + 'users.assign_role': { + methods: ["POST"], + pattern: '/api/v1/users/:id/roles', + tokens: [{"old":"/api/v1/users/:id/roles","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id/roles","type":1,"val":"id","end":""},{"old":"/api/v1/users/:id/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['users.assign_role']['types'], + }, + 'users.revoke_role': { + methods: ["DELETE"], + pattern: '/api/v1/users/:id/roles/:roleId', + tokens: [{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"api","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"v1","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"users","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":1,"val":"id","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":0,"val":"roles","end":""},{"old":"/api/v1/users/:id/roles/:roleId","type":1,"val":"roleId","end":""}], + types: placeholder as Registry['users.revoke_role']['types'], + }, + 'roles.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/roles', + tokens: [{"old":"/api/v1/roles","type":0,"val":"api","end":""},{"old":"/api/v1/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['roles.index']['types'], + }, + 'roles.show': { + methods: ["GET","HEAD"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.show']['types'], + }, + 'roles.store': { + methods: ["POST"], + pattern: '/api/v1/roles', + tokens: [{"old":"/api/v1/roles","type":0,"val":"api","end":""},{"old":"/api/v1/roles","type":0,"val":"v1","end":""},{"old":"/api/v1/roles","type":0,"val":"roles","end":""}], + types: placeholder as Registry['roles.store']['types'], + }, + 'roles.update': { + methods: ["PUT"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.update']['types'], + }, + 'roles.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/roles/:id', + tokens: [{"old":"/api/v1/roles/:id","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['roles.destroy']['types'], + }, + 'roles.assign_permission': { + methods: ["POST"], + pattern: '/api/v1/roles/:id/permissions', + tokens: [{"old":"/api/v1/roles/:id/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id/permissions","type":1,"val":"id","end":""},{"old":"/api/v1/roles/:id/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['roles.assign_permission']['types'], + }, + 'roles.revoke_permission': { + methods: ["DELETE"], + pattern: '/api/v1/roles/:id/permissions/:permissionId', + tokens: [{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"api","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"v1","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"roles","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":1,"val":"id","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":0,"val":"permissions","end":""},{"old":"/api/v1/roles/:id/permissions/:permissionId","type":1,"val":"permissionId","end":""}], + types: placeholder as Registry['roles.revoke_permission']['types'], + }, + 'permissions.index': { methods: ["GET","HEAD"], - pattern: '/api/v1/account/profile', - tokens: [{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}], - types: placeholder as Registry['profile.profile.show']['types'], + pattern: '/api/v1/permissions', + tokens: [{"old":"/api/v1/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['permissions.index']['types'], }, - 'profile.access_tokens.destroy': { + 'permissions.store': { methods: ["POST"], - pattern: '/api/v1/account/logout', - tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}], - types: placeholder as Registry['profile.access_tokens.destroy']['types'], + pattern: '/api/v1/permissions', + tokens: [{"old":"/api/v1/permissions","type":0,"val":"api","end":""},{"old":"/api/v1/permissions","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions","type":0,"val":"permissions","end":""}], + types: placeholder as Registry['permissions.store']['types'], + }, + 'permissions.update': { + methods: ["PUT"], + pattern: '/api/v1/permissions/:id', + tokens: [{"old":"/api/v1/permissions/:id","type":0,"val":"api","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"permissions","end":""},{"old":"/api/v1/permissions/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['permissions.update']['types'], + }, + 'permissions.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/permissions/:id', + tokens: [{"old":"/api/v1/permissions/:id","type":0,"val":"api","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"permissions","end":""},{"old":"/api/v1/permissions/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['permissions.destroy']['types'], }, } as const satisfies Record diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index 82c02fd..e068413 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -7,52 +7,316 @@ import type { InferInput, SimpleError } from '@vinejs/vine/types' export type ParamValue = string | number | bigint | boolean export interface Registry { - 'auth.new_account.store': { + 'health.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/health' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.register': { methods: ["POST"] - pattern: '/api/v1/auth/signup' + pattern: '/api/v1/auth/register' types: { - body: ExtractBody> + body: ExtractBody> paramsTuple: [] params: {} - query: ExtractQuery> - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } - 'auth.access_tokens.store': { + 'auth.activate': { + methods: ["POST"] + pattern: '/api/v1/auth/activate' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.login': { methods: ["POST"] pattern: '/api/v1/auth/login' types: { - body: ExtractBody> + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.logout': { + methods: ["POST"] + pattern: '/api/v1/auth/logout' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.refresh': { + methods: ["POST"] + pattern: '/api/v1/auth/refresh' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'auth.forgot_password': { + methods: ["POST"] + pattern: '/api/v1/auth/forgot-password' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.reset_password': { + methods: ["POST"] + pattern: '/api/v1/auth/reset-password' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'auth.me': { + methods: ["GET","HEAD"] + pattern: '/api/v1/auth/me' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/users' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.show': { + methods: ["GET","HEAD"] + pattern: '/api/v1/users/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.update': { + methods: ["PUT"] + pattern: '/api/v1/users/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'users.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/users/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'users.assign_role': { + methods: ["POST"] + pattern: '/api/v1/users/:id/roles' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'users.revoke_role': { + methods: ["DELETE"] + pattern: '/api/v1/users/:id/roles/:roleId' + types: { + body: {} + paramsTuple: [ParamValue, ParamValue] + params: { id: ParamValue; roleId: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/roles' + types: { + body: {} paramsTuple: [] params: {} - query: ExtractQuery> - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> } } - 'profile.profile.show': { + 'roles.show': { methods: ["GET","HEAD"] - pattern: '/api/v1/account/profile' + pattern: '/api/v1/roles/:id' types: { body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'roles.store': { + methods: ["POST"] + pattern: '/api/v1/roles' + types: { + body: ExtractBody> paramsTuple: [] params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.update': { + methods: ["PUT"] + pattern: '/api/v1/roles/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/roles/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } query: {} - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> } } - 'profile.access_tokens.destroy': { + 'roles.assign_permission': { methods: ["POST"] - pattern: '/api/v1/account/logout' + pattern: '/api/v1/roles/:id/permissions' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'roles.revoke_permission': { + methods: ["DELETE"] + pattern: '/api/v1/roles/:id/permissions/:permissionId' types: { body: {} + paramsTuple: [ParamValue, ParamValue] + params: { id: ParamValue; permissionId: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'permissions.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/permissions' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'permissions.store': { + methods: ["POST"] + pattern: '/api/v1/permissions' + types: { + body: ExtractBody> paramsTuple: [] params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'permissions.update': { + methods: ["PUT"] + pattern: '/api/v1/permissions/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'permissions.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/permissions/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } query: {} - response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> } } } diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index 3964805..811db0a 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -2,20 +2,40 @@ import type { routes } from './index.ts' export interface ApiDefinition { + health: { + index: typeof routes['health.index'] + } auth: { - newAccount: { - store: typeof routes['auth.new_account.store'] - } - accessTokens: { - store: typeof routes['auth.access_tokens.store'] - } + register: typeof routes['auth.register'] + activate: typeof routes['auth.activate'] + login: typeof routes['auth.login'] + logout: typeof routes['auth.logout'] + refresh: typeof routes['auth.refresh'] + forgotPassword: typeof routes['auth.forgot_password'] + resetPassword: typeof routes['auth.reset_password'] + me: typeof routes['auth.me'] + } + users: { + index: typeof routes['users.index'] + show: typeof routes['users.show'] + update: typeof routes['users.update'] + destroy: typeof routes['users.destroy'] + assignRole: typeof routes['users.assign_role'] + revokeRole: typeof routes['users.revoke_role'] + } + roles: { + index: typeof routes['roles.index'] + show: typeof routes['roles.show'] + store: typeof routes['roles.store'] + update: typeof routes['roles.update'] + destroy: typeof routes['roles.destroy'] + assignPermission: typeof routes['roles.assign_permission'] + revokePermission: typeof routes['roles.revoke_permission'] } - profile: { - profile: { - show: typeof routes['profile.profile.show'] - } - accessTokens: { - destroy: typeof routes['profile.access_tokens.destroy'] - } + permissions: { + index: typeof routes['permissions.index'] + store: typeof routes['permissions.store'] + update: typeof routes['permissions.update'] + destroy: typeof routes['permissions.destroy'] } } diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 91cd9f0..dfc2309 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -4,5 +4,9 @@ */ export const controllers = { + Auth: () => import('#controllers/auth_controller'), Health: () => import('#controllers/health_controller'), + Permissions: () => import('#controllers/permissions_controller'), + Roles: () => import('#controllers/roles_controller'), + Users: () => import('#controllers/users_controller'), } diff --git a/.adonisjs/server/policies.ts b/.adonisjs/server/policies.ts new file mode 100644 index 0000000..a2d4a6c --- /dev/null +++ b/.adonisjs/server/policies.ts @@ -0,0 +1,3 @@ +export const policies = { +} + diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index 9aa155f..a3d18bf 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -4,21 +4,75 @@ type ParamValue = string | number | bigint | boolean export type ScannedRoutes = { ALL: { - 'auth.new_account.store': { paramsTuple?: []; params?: {} } - 'auth.access_tokens.store': { paramsTuple?: []; params?: {} } - 'profile.profile.show': { paramsTuple?: []; params?: {} } - 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.activate': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'auth.refresh': { paramsTuple?: []; params?: {} } + 'auth.forgot_password': { paramsTuple?: []; params?: {} } + 'auth.reset_password': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.assign_role': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.revoke_role': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'roleId': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.store': { paramsTuple?: []; params?: {} } + 'roles.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.assign_permission': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.revoke_permission': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'permissionId': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } + 'permissions.store': { paramsTuple?: []; params?: {} } + 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } GET: { - 'profile.profile.show': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } } HEAD: { - 'profile.profile.show': { paramsTuple?: []; params?: {} } + 'health.index': { paramsTuple?: []; params?: {} } + 'auth.me': { paramsTuple?: []; params?: {} } + 'users.index': { paramsTuple?: []; params?: {} } + 'users.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.index': { paramsTuple?: []; params?: {} } + 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.index': { paramsTuple?: []; params?: {} } } POST: { - 'auth.new_account.store': { paramsTuple?: []; params?: {} } - 'auth.access_tokens.store': { paramsTuple?: []; params?: {} } - 'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} } + 'auth.register': { paramsTuple?: []; params?: {} } + 'auth.activate': { paramsTuple?: []; params?: {} } + 'auth.login': { paramsTuple?: []; params?: {} } + 'auth.logout': { paramsTuple?: []; params?: {} } + 'auth.refresh': { paramsTuple?: []; params?: {} } + 'auth.forgot_password': { paramsTuple?: []; params?: {} } + 'auth.reset_password': { paramsTuple?: []; params?: {} } + 'users.assign_role': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.store': { paramsTuple?: []; params?: {} } + 'roles.assign_permission': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.store': { paramsTuple?: []; params?: {} } + } + PUT: { + 'users.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + } + DELETE: { + 'users.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'users.revoke_role': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'roleId': ParamValue} } + 'roles.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'roles.revoke_permission': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'permissionId': ParamValue} } + 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } } declare module '@adonisjs/core/types/http' { diff --git a/.env.development b/.env.development index 9f6739f..89ae036 100644 --- a/.env.development +++ b/.env.development @@ -25,3 +25,10 @@ HASH_DRIVER=argon LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 + +# ── Mail ────────────────────────────────────────────────── +MAIL_MAILER=smtp +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 diff --git a/.env.example b/.env.example index 13d5f98..bc56405 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,10 @@ HASH_DRIVER=argon LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 + +# ── Mail ────────────────────────────────────────────────── +MAIL_MAILER=smtp # only smtp is configured, pointed at Mailhog +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 # Mailhog SMTP port from docker-compose.yml (1026 for docker-compose.test.yml) diff --git a/.env.test b/.env.test index f3eba82..28e3b08 100644 --- a/.env.test +++ b/.env.test @@ -21,6 +21,12 @@ LIMITER_STORE=database THROTTLE_AUTH_MAX=10 THROTTLE_AUTH_WINDOW=60 +MAIL_MAILER=smtp +MAIL_FROM_NAME=AdonisJS Tutorial +MAIL_FROM_ADDRESS=no-reply@adonisjs-tutorial.test +SMTP_HOST=127.0.0.1 +SMTP_PORT=1026 + TEST_ADMIN_EMAIL=admin@example.com TEST_ADMIN_PASSWORD=Admin1234! TEST_USER_EMAIL=user@example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3fc45..e975b18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ env: LIMITER_STORE: database THROTTLE_AUTH_MAX: 5 THROTTLE_AUTH_WINDOW: 60 + MAIL_MAILER: smtp + MAIL_FROM_NAME: AdonisJS Tutorial + MAIL_FROM_ADDRESS: no-reply@adonisjs-tutorial.test + SMTP_HOST: 127.0.0.1 + SMTP_PORT: 1025 jobs: ci: @@ -46,6 +51,12 @@ jobs: --health-timeout 3s --health-retries 5 + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 21ae17e..527490a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,6 +29,11 @@ env: LIMITER_STORE: database THROTTLE_AUTH_MAX: 5 THROTTLE_AUTH_WINDOW: 60 + MAIL_MAILER: smtp + MAIL_FROM_NAME: AdonisJS Tutorial + MAIL_FROM_ADDRESS: no-reply@adonisjs-tutorial.test + SMTP_HOST: 127.0.0.1 + SMTP_PORT: 1025 jobs: checks: @@ -52,6 +57,14 @@ jobs: --health-timeout 3s --health-retries 5 + # Catches registration and password reset emails so functional tests + # can assert on what was sent without any real delivery. + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/README.md b/README.md index 147878a..8de7d0e 100644 --- a/README.md +++ b/README.md @@ -844,6 +844,28 @@ router.group(() => { | POST | `/api/v1/auth/reset-password` | Consume token, set new password | Public | | GET | `/api/v1/auth/me` | Current user profile | Authenticated | +**User, role and permission administration.** Registration always assigns the default `USER` role and nothing else in the app manages `users`, `roles` or `permissions` afterward, promoting an account to `ADMIN`, creating a new role, or changing what a role can do would otherwise only be possible by hand in the database. These endpoints close that gap, all `ADMIN` only since they operate on other accounts and on the authorization model itself. + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/api/v1/users` | Paginated list of users | ADMIN | +| GET | `/api/v1/users/:id` | User detail with roles preloaded | ADMIN | +| PUT | `/api/v1/users/:id` | Update `firstName`, `lastName`, `email`, `enabled`, `accountLocked` | ADMIN | +| DELETE | `/api/v1/users/:id` | Delete a user | ADMIN | +| POST | `/api/v1/users/:id/roles` | Assign a role to a user | ADMIN | +| DELETE | `/api/v1/users/:id/roles/:roleId` | Revoke a role from a user | ADMIN | +| GET | `/api/v1/roles` | Paginated list of roles | ADMIN | +| GET | `/api/v1/roles/:id` | Role detail with permissions preloaded | ADMIN | +| POST | `/api/v1/roles` | Create a role | ADMIN | +| PUT | `/api/v1/roles/:id` | Update a role | ADMIN | +| DELETE | `/api/v1/roles/:id` | Delete a role, blocked with 409 while any user still has it | ADMIN | +| POST | `/api/v1/roles/:id/permissions` | Assign a permission to a role | ADMIN | +| DELETE | `/api/v1/roles/:id/permissions/:permissionId` | Revoke a permission from a role | ADMIN | +| GET | `/api/v1/permissions` | Paginated list of permissions | ADMIN | +| POST | `/api/v1/permissions` | Create a permission (`resource`, `action`) | ADMIN | +| PUT | `/api/v1/permissions/:id` | Update a permission | ADMIN | +| DELETE | `/api/v1/permissions/:id` | Delete a permission, blocked with 409 while any role still has it | ADMIN | + ### Tasks - [ ] Install and configure `@adonisjs/auth` with the JWT guard; configure `config/auth.ts` @@ -868,6 +890,17 @@ router.group(() => { - [ ] Functional test (full auth flow): register -> activate -> login -> `GET /auth/me` -> logout -> `GET /auth/me` with revoked token returns 401 - [ ] Functional test (middleware smoke): protected stub route returns 401 without token, 200 with valid `USER` token; same route with `role('ADMIN')` returns 403 for `USER`, 200 for `ADMIN` - [ ] Functional test (rate limiting): 11th login attempt within 60 s returns 429 +- [ ] Create `userValidator` (`updateUserSchema`), `roleValidator` (`createRoleSchema`, `updateRoleSchema`), `permissionValidator` (`createPermissionSchema`, `updatePermissionSchema`), all VineJS, all `ADMIN` only +- [ ] Implement `UsersService`: `findAll(page, limit)`, `findOne(id)`, `update(id, data)`, `remove(id)`, `assignRole(id, roleId)`, `revokeRole(id, roleId)`; `assignRole`/`revokeRole` are idempotent, no error re-assigning a role the user already has +- [ ] Implement `RolesService`: `findAll(page, limit)`, `findOne(id)`, `create(data)`, `update(id, data)`, `remove(id)`, `assignPermission(id, permissionId)`, `revokePermission(id, permissionId)` +- [ ] Business rule: a role with at least one user still assigned cannot be deleted, throw HTTP 409 +- [ ] Implement `PermissionsService`: `findAll(page, limit)`, `findOne(id)`, `create(data)`, `update(id, data)`, `remove(id)` +- [ ] Business rule: a permission still attached to at least one role cannot be deleted, throw HTTP 409 +- [ ] Create `UsersController`, `RolesController`, `PermissionsController`, thin, `@inject()`, calling their services and using `respond()` +- [ ] Declare all administration routes under `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` +- [ ] Unit test: `UsersService.assignRole` attaches without duplicating an existing pivot row; `RolesService.remove` throws 409 when users are still assigned; `PermissionsService.remove` throws 409 when a role still has it +- [ ] Functional test: full CRUD lifecycle for users, roles and permissions; assigning and revoking a role on a user changes what that user's JWT is authorized to do on a subsequent request; all responses match `ApiResponse` shape +- [ ] Functional test (auth): every administration route returns 401 without a token and 403 with a non `ADMIN` token --- diff --git a/adonisrc.ts b/adonisrc.ts index a96390d..e781201 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -1,6 +1,7 @@ import { indexEntities } from '@adonisjs/core' import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@tuyau/core/hooks' +import { indexPolicies } from '@adonisjs/bouncer' export default defineConfig({ /* @@ -28,6 +29,8 @@ export default defineConfig({ () => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands'), () => import('@adonisjs/session/commands'), + () => import('@adonisjs/bouncer/commands'), + () => import('@adonisjs/mail/commands'), ], /* @@ -53,6 +56,9 @@ export default defineConfig({ () => import('@adonisjs/cors/cors_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('#providers/api_provider'), + () => import('@adonisjs/bouncer/bouncer_provider'), + () => import('@adonisjs/limiter/limiter_provider'), + () => import('@adonisjs/mail/mail_provider'), ], /* @@ -111,6 +117,7 @@ export default defineConfig({ transformers: { enabled: true }, }), generateRegistry(), + indexPolicies(), ], }, }) diff --git a/app/abilities/main.ts b/app/abilities/main.ts new file mode 100644 index 0000000..9c4fc0d --- /dev/null +++ b/app/abilities/main.ts @@ -0,0 +1,23 @@ +/* +|-------------------------------------------------------------------------- +| Bouncer abilities +|-------------------------------------------------------------------------- +| +| You may export multiple abilities from this file and pre-register them +| when creating the Bouncer instance. +| +| Pre-registered policies and abilities can be referenced as a string by their +| name. Also they are must if want to perform authorization inside Edge +| templates. +| +*/ + +import { Bouncer } from '@adonisjs/bouncer' + +/** + * Delete the following ability to start from + * scratch + */ +export const editUser = Bouncer.ability(() => { + return true +}) diff --git a/app/auth/jwt_guard.ts b/app/auth/jwt_guard.ts new file mode 100644 index 0000000..77d1271 --- /dev/null +++ b/app/auth/jwt_guard.ts @@ -0,0 +1,143 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { errors, symbols } from '@adonisjs/auth' +import type { AuthClientResponse, GuardContract } from '@adonisjs/auth/types' +import { SignJWT, jwtVerify } from 'jose' +import env from '#start/env' +import User from '#models/user' + +/** + * The installed @adonisjs/auth version ships session, basic auth and access + * tokens guards only, no JWT guard. This is a minimal custom guard following + * the package's own GuardContract, backed by jose for signing and + * verification, so the rest of the app can call `ctx.auth.use('jwt')` the + * same way it would call any built in guard. + * + * The guard's only job is to prove the token is validly signed, not expired, + * and resolve to a real User. It deliberately does not check the jti + * blacklist or the user's enabled/accountLocked flags, those are + * AuthMiddleware's job, layered on top of authenticate() rather than baked + * into the guard, so a route that only needs "is this a valid token" without + * the blacklist round trip can use the guard directly. + */ +export type JwtPayload = { + sub: string + jti: string + iat: number + exp: number +} + +function secretKey() { + // JWT_SECRET is Env.schema.secret(), so env.get() returns a Secret + // that redacts itself from logs and console output. release() is the only + // way to get the raw value back out, needed here since TextEncoder has no + // awareness of Secret the way the encryption driver in config/encryption.ts + // does. + return new TextEncoder().encode(env.get('JWT_SECRET').release()) +} + +/** + * JWT_EXPIRY is documented as either a bare number of seconds ("3600") or a + * jose style time span ("7d", "24h"). jose's setExpirationTime treats a + * plain JS number as an absolute Unix timestamp, not a duration, so passing + * Number("3600") would set exp to one hour past the epoch, a token that is + * already expired the instant it is issued. A purely numeric string is + * given an explicit "s" suffix instead, so jose's own relative time span + * parser handles it the same way it handles "7d" or "24h". + */ +function expiresIn(): string { + const value = env.get('JWT_EXPIRY') + return /^\d+$/.test(value) ? `${value}s` : value +} + +export async function signJwt(user: User): Promise { + return new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(String(user.id)) + .setJti(crypto.randomUUID()) + .setIssuedAt() + .setExpirationTime(expiresIn()) + .sign(secretKey()) +} + +export class JwtGuard implements GuardContract { + #ctx: HttpContext + + readonly driverName = 'jwt' as const + user?: User + payload?: JwtPayload + /** The raw bearer token this request authenticated with, set once authenticate() succeeds. */ + token?: string + isAuthenticated = false + authenticationAttempted = false; + + [symbols.GUARD_KNOWN_EVENTS]: unknown + + constructor(ctx: HttpContext) { + this.#ctx = ctx + } + + getUserOrFail(): User { + if (!this.user) { + throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + return this.user + } + + async authenticate(): Promise { + if (this.authenticationAttempted) { + return this.getUserOrFail() + } + this.authenticationAttempted = true + + const authHeader = this.#ctx.request.header('authorization') + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null + if (!token) { + throw new errors.E_UNAUTHORIZED_ACCESS('No JWT token provided', { + guardDriverName: this.driverName, + }) + } + + let payload: JwtPayload + try { + const result = await jwtVerify(token, secretKey()) + payload = result.payload + } catch { + throw new errors.E_UNAUTHORIZED_ACCESS('Invalid or expired JWT token', { + guardDriverName: this.driverName, + }) + } + + const user = await User.find(Number(payload.sub)) + if (!user) { + throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', { + guardDriverName: this.driverName, + }) + } + + this.payload = payload + this.user = user + this.token = token + this.isAuthenticated = true + return user + } + + async check(): Promise { + try { + await this.authenticate() + return true + } catch { + return false + } + } + + async authenticateAsClient(user: User): Promise { + const token = await signJwt(user) + return { headers: { authorization: `Bearer ${token}` } } + } +} + +export function jwtGuard() { + return (ctx: HttpContext) => new JwtGuard(ctx) +} diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts new file mode 100644 index 0000000..99be102 --- /dev/null +++ b/app/controllers/auth_controller.ts @@ -0,0 +1,87 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import AuthService from '#services/auth_service' +import { + registerSchema, + activateSchema, + loginSchema, + forgotPasswordSchema, + resetPasswordSchema, +} from '#validators/auth_validator' + +/** + * Thin HTTP layer over AuthService: every action validates its input with + * a compiled VineJS schema, delegates to the service, and wraps the + * result with `respond()`. No business logic and no direct model access + * live here, per the project's hard rules; AuthService owns all of that. + */ +@inject() +export default class AuthController { + constructor(private authService: AuthService) {} + + async register({ request, response }: HttpContext) { + const data = await request.validateUsing(registerSchema) + const user = await this.authService.register(data) + return response.created( + respond(user, 'User registered, check your email to activate your account') + ) + } + + async activate({ request, response }: HttpContext) { + const { token } = await request.validateUsing(activateSchema) + const user = await this.authService.activate(token) + return response.ok(respond(user, 'Account activated')) + } + + async login({ request, response }: HttpContext) { + const data = await request.validateUsing(loginSchema) + const result = await this.authService.login(data) + return response.ok(respond(result, 'Login successful')) + } + + /** + * `middleware.auth()` on this route has already authenticated the + * request and rejected a blacklisted jti before this action ever runs, + * so the guard's cached user, payload and token are read directly rather + * than re-authenticating or re-parsing the Authorization header. Reading + * `guard.token` instead of parsing the header a second time keeps token + * extraction defined in exactly one place, JwtGuard.authenticate(), so + * this controller cannot drift out of sync with what the guard itself + * considers a valid bearer token. + */ + async logout({ auth, response }: HttpContext) { + const guard = auth.use('jwt') + const user = guard.getUserOrFail() + const payload = guard.payload! + const rawToken = guard.token! + + await this.authService.logout(user, payload, rawToken) + return response.ok(respond(null, 'Logged out successfully')) + } + + async refresh({ auth, response }: HttpContext) { + const user = auth.use('jwt').getUserOrFail() + const token = await this.authService.refresh(user) + return response.ok(respond({ token }, 'Token refreshed')) + } + + async forgotPassword({ request, response }: HttpContext) { + const { email } = await request.validateUsing(forgotPasswordSchema) + await this.authService.forgotPassword(email) + return response.ok(respond(null, 'Password reset email sent')) + } + + async resetPassword({ request, response }: HttpContext) { + const data = await request.validateUsing(resetPasswordSchema) + const user = await this.authService.resetPassword(data) + return response.ok(respond(user, 'Password reset successful')) + } + + async me({ auth, response }: HttpContext) { + const user = auth.use('jwt').getUserOrFail() + const result = await this.authService.me(user) + return response.ok(respond(result, 'Current user profile')) + } +} diff --git a/app/controllers/permissions_controller.ts b/app/controllers/permissions_controller.ts new file mode 100644 index 0000000..0f8bbc8 --- /dev/null +++ b/app/controllers/permissions_controller.ts @@ -0,0 +1,44 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import PermissionsService from '#services/permissions_service' +import { createPermissionSchema, updatePermissionSchema } from '#validators/permission_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over PermissionsService. Every route this controller + * serves is declared behind `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` + * in start/routes.ts, same as UsersController and RolesController. + * + * No `show` action: the README's Endpoints table has no `GET + * /api/v1/permissions/:id` row, unlike users and roles, so none is added + * here even though PermissionsService.findOne exists for internal reuse. + */ +@inject() +export default class PermissionsController { + constructor(private permissionsService: PermissionsService) {} + + async index({ request, response }: HttpContext) { + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.permissionsService.findAll(page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createPermissionSchema) + const permission = await this.permissionsService.create(data) + return response.created(respond(permission, 'Permission created')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updatePermissionSchema) + const permission = await this.permissionsService.update(Number(params.id), data) + return response.ok(respond(permission, 'Permission updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.permissionsService.remove(Number(params.id)) + return response.ok(respond(null, 'Permission deleted')) + } +} diff --git a/app/controllers/roles_controller.ts b/app/controllers/roles_controller.ts new file mode 100644 index 0000000..5e2b806 --- /dev/null +++ b/app/controllers/roles_controller.ts @@ -0,0 +1,63 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import RolesService from '#services/roles_service' +import { + createRoleSchema, + updateRoleSchema, + assignPermissionSchema, +} from '#validators/role_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over RolesService. Every route this controller serves is + * declared behind `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` + * in start/routes.ts, same as UsersController. + */ +@inject() +export default class RolesController { + constructor(private rolesService: RolesService) {} + + async index({ request, response }: HttpContext) { + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.rolesService.findAll(page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + async show({ params, response }: HttpContext) { + const role = await this.rolesService.findOne(Number(params.id)) + return response.ok(respond(role, 'Role detail')) + } + + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createRoleSchema) + const role = await this.rolesService.create(data) + return response.created(respond(role, 'Role created')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateRoleSchema) + const role = await this.rolesService.update(Number(params.id), data) + return response.ok(respond(role, 'Role updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.rolesService.remove(Number(params.id)) + return response.ok(respond(null, 'Role deleted')) + } + + async assignPermission({ params, request, response }: HttpContext) { + const { permissionId } = await request.validateUsing(assignPermissionSchema) + const role = await this.rolesService.assignPermission(Number(params.id), permissionId) + return response.created(respond(role, 'Permission assigned')) + } + + async revokePermission({ params, response }: HttpContext) { + const role = await this.rolesService.revokePermission( + Number(params.id), + Number(params.permissionId) + ) + return response.ok(respond(role, 'Permission revoked')) + } +} diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts new file mode 100644 index 0000000..553ce7e --- /dev/null +++ b/app/controllers/users_controller.ts @@ -0,0 +1,52 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import UsersService from '#services/users_service' +import { updateUserSchema, assignRoleSchema } from '#validators/user_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over UsersService: every action validates its input (or + * reads a route param), delegates to the service, and wraps the result + * with `respond()`. Every route this controller serves is declared behind + * `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` in + * start/routes.ts, so no per-action authorization check is repeated here. + */ +@inject() +export default class UsersController { + constructor(private usersService: UsersService) {} + + async index({ request, response }: HttpContext) { + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.usersService.findAll(page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + async show({ params, response }: HttpContext) { + const user = await this.usersService.findOne(Number(params.id)) + return response.ok(respond(user, 'User detail')) + } + + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateUserSchema) + const user = await this.usersService.update(Number(params.id), data) + return response.ok(respond(user, 'User updated')) + } + + async destroy({ params, response }: HttpContext) { + await this.usersService.remove(Number(params.id)) + return response.ok(respond(null, 'User deleted')) + } + + async assignRole({ params, request, response }: HttpContext) { + const { roleId } = await request.validateUsing(assignRoleSchema) + const user = await this.usersService.assignRole(Number(params.id), roleId) + return response.created(respond(user, 'Role assigned')) + } + + async revokeRole({ params, response }: HttpContext) { + const user = await this.usersService.revokeRole(Number(params.id), Number(params.roleId)) + return response.ok(respond(user, 'Role revoked')) + } +} diff --git a/app/exceptions/handler.ts b/app/exceptions/handler.ts index 9b728c1..a9edd51 100644 --- a/app/exceptions/handler.ts +++ b/app/exceptions/handler.ts @@ -14,6 +14,7 @@ import app from '@adonisjs/core/services/app' import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http' import { errors as vineErrors } from '@vinejs/vine' +import { fail } from '#helpers/api_response' export default class HttpExceptionHandler extends ExceptionHandler { /** @@ -46,33 +47,21 @@ export default class HttpExceptionHandler extends ExceptionHandler { // VineJS validation errors: preserve structured field messages if (error instanceof vineErrors.E_VALIDATION_ERROR) { - return response.status(422).json({ - success: false, - message: 'Validation failed', - errors: error.messages, - timestamp: new Date().toISOString(), - path: request.url(), - }) + return response.status(422).json(fail('Validation failed', request.url(), error.messages)) } // AdonisJS HTTP exceptions carry a numeric status code const httpError = error as { status?: number; message?: string } - if (httpError?.status && typeof httpError.status === 'number') { - return response.status(httpError.status).json({ - success: false, - message: httpError.message ?? 'An error occurred', - timestamp: new Date().toISOString(), - path: request.url(), - }) + if (typeof httpError?.status === 'number') { + return response + .status(httpError.status) + .json(fail(httpError.message ?? 'An error occurred', request.url())) } // Unexpected server errors — mask details in production - return response.status(500).json({ - success: false, - message: app.inProduction ? 'Internal server error' : String(error), - timestamp: new Date().toISOString(), - path: request.url(), - }) + return response + .status(500) + .json(fail(app.inProduction ? 'Internal server error' : String(error), request.url())) } /** diff --git a/app/helpers/api_response.ts b/app/helpers/api_response.ts index 85b42ef..a5144fc 100644 --- a/app/helpers/api_response.ts +++ b/app/helpers/api_response.ts @@ -46,3 +46,27 @@ export function respond(data: T, message = 'OK') { timestamp: new Date().toISOString(), } } + +/** + * Constructs a failed ApiResponse envelope. + * + * Was previously hand-built independently at five call sites (three in the + * global exception handler, one each in AuthMiddleware and RoleMiddleware), + * all constructing the same `{ success: false, message, timestamp, path }` + * shape by hand. This is the error-side counterpart to `respond()`, kept in + * the same file since both exist to guarantee one envelope shape across the + * whole API surface. + * + * @param message - A short human-readable description of what failed. + * @param path - The request path, for error tracing. + * @param errors - Field-level validation messages, when applicable. + */ +export function fail(message: string, path: string, errors?: string[]) { + return { + success: false, + message, + ...(errors ? { errors } : {}), + timestamp: new Date().toISOString(), + path, + } +} diff --git a/app/helpers/page_response.ts b/app/helpers/page_response.ts index bc3e3d1..a5e5a40 100644 --- a/app/helpers/page_response.ts +++ b/app/helpers/page_response.ts @@ -1,3 +1,5 @@ +import type { LucidRow, ModelPaginatorContract } from '@adonisjs/lucid/types/model' + /** * Typed pagination envelope for all list endpoints. * @@ -6,23 +8,11 @@ * would couple consumers to implementation specifics that can change across * ORM versions. * - * Services map the paginator output to this stable interface before returning - * it, so controllers and API clients depend only on this contract. + * Services map the paginator output to this stable interface, via + * `toPageResponse()` below, before returning it, so controllers and API + * clients depend only on this contract. * * @template T - The type of each item in the paginated collection. - * - * @example - * // In a service method - * const paginator = await Category.query().paginate(page, limit) - * const result: PageResponse = { - * items: paginator.all(), - * total: paginator.total, - * page: paginator.currentPage, - * limit: paginator.perPage, - * totalPages: paginator.lastPage, - * hasNext: paginator.hasMorePages, - * hasPrevious: paginator.currentPage > 1, - * } */ export interface PageResponse { /** The records on the current page. */ @@ -40,3 +30,30 @@ export interface PageResponse { /** True when the current page is greater than 1. */ hasPrevious: boolean } + +/** + * Maps a Lucid ModelPaginatorContract onto PageResponse. This was + * originally copy-pasted identically into UsersService, RolesService and + * PermissionsService; factored into the one place PageResponse itself is + * defined so every list endpoint, including the categories/products/ + * customers/orders services added by later branches, shares a single + * implementation instead of a growing set of hand-synced copies. + * + * @example + * // In a service method + * const paginator = await Category.query().paginate(page, limit) + * return toPageResponse(paginator) + */ +export function toPageResponse( + paginator: ModelPaginatorContract +): PageResponse { + return { + items: paginator.all(), + total: paginator.total, + page: paginator.currentPage, + limit: paginator.perPage, + totalPages: paginator.lastPage, + hasNext: paginator.hasMorePages, + hasPrevious: paginator.currentPage > 1, + } +} diff --git a/app/mails/activation_mail.ts b/app/mails/activation_mail.ts new file mode 100644 index 0000000..42e1c62 --- /dev/null +++ b/app/mails/activation_mail.ts @@ -0,0 +1,56 @@ +import { BaseMail } from '@adonisjs/mail' +import env from '#start/env' +import type User from '#models/user' +import { escapeHtml } from '#mails/escape_html' + +/** + * Sent once, right after AuthService.register creates the account. The + * activation link carries the raw ActivationToken value that + * AuthService.activate later looks up by exact match; this class never + * touches the database itself, it only formats what AuthService already + * generated and persisted. + * + * This project has no Edge/view provider (see config/mail.ts's own note), + * so the body is built as a plain string right here instead of being + * rendered from a template file. Both a text and an HTML view are set so + * the message renders correctly regardless of the mail client's + * capabilities; Mailhog (this project's only mail sink, in every + * environment) shows both. + */ +export default class ActivationMail extends BaseMail { + subject = 'Activate your account' + + constructor( + private user: User, + private token: string + ) { + super() + } + + /** + * NOTE: `/activate?token=...` is not a real route anywhere in this + * API-only project (there is no frontend to receive the redirect); the + * README explicitly allows a placeholder link here since the goal is + * only for the token to be readable in Mailhog, not to be clickable + * end-to-end. + */ + prepare() { + const activationUrl = `${env.get('APP_URL')}/activate?token=${this.token}` + + this.message + .to(this.user.email) + .subject(this.subject) + .text( + `Hi ${this.user.firstName},\n\n` + + `Welcome to AdonisJS Tutorial. Activate your account by visiting the link below:\n\n` + + `${activationUrl}\n\n` + + `This link expires in 24 hours.` + ) + .html( + `

Hi ${escapeHtml(this.user.firstName)},

` + + `

Welcome to AdonisJS Tutorial. Activate your account by clicking the link below:

` + + `

${activationUrl}

` + + `

This link expires in 24 hours.

` + ) + } +} diff --git a/app/mails/escape_html.ts b/app/mails/escape_html.ts new file mode 100644 index 0000000..0f6e6ef --- /dev/null +++ b/app/mails/escape_html.ts @@ -0,0 +1,19 @@ +/** + * Escapes the five HTML-significant characters in untrusted user input + * before it is interpolated into a mail's `.html()` body. + * + * registerSchema only bounds firstName/lastName by length (README's Auth + * Model column widths), not by character set, so a value like + * `` passes validation and would otherwise + * render live in whatever HTML-capable client opens the mail, Mailhog in + * every environment this project runs in. The `.text()` body next to it + * needs no escaping, plain text has no markup to inject into. + */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/app/mails/password_reset_mail.ts b/app/mails/password_reset_mail.ts new file mode 100644 index 0000000..0584bf0 --- /dev/null +++ b/app/mails/password_reset_mail.ts @@ -0,0 +1,51 @@ +import { BaseMail } from '@adonisjs/mail' +import env from '#start/env' +import type User from '#models/user' +import { escapeHtml } from '#mails/escape_html' + +/** + * Sent by AuthService.forgotPassword after a PasswordResetToken row is + * persisted. The token is only ever readable by whoever receives this + * email (or, in this tutorial, whoever opens Mailhog); it is never echoed + * back in the HTTP response, since a response body is a far more likely + * leak vector than an email sent to the account's own address. + * + * Shorter-lived than ActivationMail's link (1 hour vs 24), matching + * PASSWORD_RESET_TOKEN_TTL_HOURS in auth_service.ts, so the copy below + * states that explicitly rather than repeating a generic expiry claim. + */ +export default class PasswordResetMail extends BaseMail { + subject = 'Reset your password' + + constructor( + private user: User, + private token: string + ) { + super() + } + + /** + * NOTE: same placeholder-link reasoning as ActivationMail — this is an + * API-only project, `/reset-password?token=...` does not resolve to a + * real frontend route, it only needs to be readable in Mailhog. + */ + prepare() { + const resetUrl = `${env.get('APP_URL')}/reset-password?token=${this.token}` + + this.message + .to(this.user.email) + .subject(this.subject) + .text( + `Hi ${this.user.firstName},\n\n` + + `We received a request to reset your password. Visit the link below to choose a new one:\n\n` + + `${resetUrl}\n\n` + + `This link expires in 1 hour. If you did not request this, you can ignore this email.` + ) + .html( + `

Hi ${escapeHtml(this.user.firstName)},

` + + `

We received a request to reset your password. Click the link below to choose a new one:

` + + `

${resetUrl}

` + + `

This link expires in 1 hour. If you did not request this, you can ignore this email.

` + ) + } +} diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index 8c86950..814ba7e 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -1,10 +1,32 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' import type { Authenticators } from '@adonisjs/auth/types' +import BlacklistedToken from '#models/blacklisted_token' +import { fail } from '#helpers/api_response' /** - * Auth middleware is used authenticate HTTP requests and deny - * access to unauthenticated users. + * Auth middleware authenticates HTTP requests and denies access to + * unauthenticated users. It also rejects requests carrying a JWT that has + * been explicitly revoked (logout writes the token's `jti` into + * `blacklisted_tokens`), and requests for a user an ADMIN has since + * disabled or locked. + * + * The `jti` and `enabled`/`accountLocked` checks live here rather than + * inside JwtGuard itself. JwtGuard (app/auth/jwt_guard.ts) is deliberately + * scoped to proving a token is validly signed, not expired, and resolves to + * a real user, nothing more. Both of these are application-level policy, + * not a property of the token's cryptographic validity, so they are + * layered on top of `authenticateUsing()` here instead of being baked into + * the guard. Every route that applies this middleware gets the check; + * nothing bypasses it by calling the guard directly, since the guard is + * never used outside this middleware and RoleMiddleware (which always runs + * after this one). + * + * Without the enabled/accountLocked recheck, a JWT issued before an ADMIN + * disables or locks that account (via PUT /api/v1/users/:id) would keep + * authenticating successfully against every protected route until the + * token's own expiry, since AuthService only checks those flags at login + * and refresh time, not on every request. */ export default class AuthMiddleware { async handle( @@ -14,7 +36,26 @@ export default class AuthMiddleware { guards?: (keyof Authenticators)[] } = {} ) { - await ctx.auth.authenticateUsing(options.guards) + const user = await ctx.auth.authenticateUsing(options.guards) + + if (!user.enabled || user.accountLocked) { + return ctx.response.unauthorized(fail('Account is no longer active', ctx.request.url())) + } + + const payload = ctx.auth.use('jwt').payload + const jti = payload?.jti + + // NOTE: `jti` is only present when the request actually authenticated + // via the jwt guard. If a future guard is added to `options.guards` + // and wins the authentication race, there is no jti to check against + // the blacklist and this step is a no-op rather than a hard failure. + if (jti) { + const blacklisted = await BlacklistedToken.findBy('jti', jti) + if (blacklisted) { + return ctx.response.unauthorized(fail('Token has been revoked', ctx.request.url())) + } + } + return next() } } diff --git a/app/middleware/initialize_bouncer_middleware.ts b/app/middleware/initialize_bouncer_middleware.ts new file mode 100644 index 0000000..6ab6d4c --- /dev/null +++ b/app/middleware/initialize_bouncer_middleware.ts @@ -0,0 +1,36 @@ +import * as abilities from '#abilities/main' +import { policies } from '#generated/policies' + +import { Bouncer } from '@adonisjs/bouncer' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Init bouncer middleware is used to create a bouncer instance + * during an HTTP request + */ +export default class InitializeBouncerMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + /** + * Create bouncer instance for the ongoing HTTP request. + * We will pull the user from the HTTP context. + */ + ctx.bouncer = new Bouncer( + () => ctx.auth.user || null, + abilities, + policies + ).setContainerResolver(ctx.containerResolver) + + return next() + } +} + +declare module '@adonisjs/core/http' { + export interface HttpContext { + bouncer: Bouncer< + Exclude, + typeof abilities, + typeof policies + > + } +} diff --git a/app/middleware/role_middleware.ts b/app/middleware/role_middleware.ts index f7f0d90..70fe77a 100644 --- a/app/middleware/role_middleware.ts +++ b/app/middleware/role_middleware.ts @@ -1,5 +1,6 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' +import { fail } from '#helpers/api_response' /** * Role-based access control middleware. @@ -13,14 +14,30 @@ import type { NextFn } from '@adonisjs/core/types/http' * single middleware class enforce different role requirements on different * routes without duplicating logic. * - * Full implementation (JWT lookup, role preloading, forbidden response) is - * added in feature/auth once the User model and auth guard are in place. - * Registering the stub here lets route declarations reference `middleware.role()` - * from day one without causing import errors. + * This middleware always runs after `middleware.auth()` on a route (never + * standalone, per the constraint that auth and role middleware are wired + * at route declaration time), so the jti blacklist check has already + * happened by the time `handle()` runs here. Calling `auth.authenticate()` + * again below is cheap: JwtGuard's `authenticationAttempted` flag makes a + * second call within the same request return the already-resolved user + * instead of re-verifying the token. */ export default class RoleMiddleware { - /** Passes the request through unconditionally until feature/auth wires the real check. */ - async handle(_ctx: HttpContext, next: NextFn, _options: { roles: string[] }) { + async handle(ctx: HttpContext, next: NextFn, options: { roles: string[] }) { + await ctx.auth.authenticate() + const user = ctx.auth.user! + + // `auth.authenticate()` resolves the user but never preloads relations. + // Reading `user.roles` without this call would silently see an empty + // array and reject every request, the exact bug called out in the + // README's troubleshooting section for this middleware. + await user.load('roles') + + const hasRole = user.roles.some((role) => options.roles.includes(role.roleName)) + if (!hasRole) { + return ctx.response.forbidden(fail('Insufficient role', ctx.request.url())) + } + return next() } } diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts new file mode 100644 index 0000000..3a754d0 --- /dev/null +++ b/app/services/auth_service.ts @@ -0,0 +1,329 @@ +import { errors as authErrors } from '@adonisjs/auth' +import { createError } from '@adonisjs/core/exceptions' +import hash from '@adonisjs/core/services/hash' +import logger from '@adonisjs/core/services/logger' +import db from '@adonisjs/lucid/services/db' +import mail from '@adonisjs/mail/services/main' +import { DateTime } from 'luxon' +import type { Infer } from '@vinejs/vine/types' + +import User from '#models/user' +import Role from '#models/role' +import ActivationToken from '#models/activation_token' +import BlacklistedToken from '#models/blacklisted_token' +import PasswordResetToken from '#models/password_reset_token' +import { signJwt, type JwtPayload } from '#auth/jwt_guard' +import ActivationMail from '#mails/activation_mail' +import PasswordResetMail from '#mails/password_reset_mail' +import type { registerSchema, loginSchema, resetPasswordSchema } from '#validators/auth_validator' + +type RegisterPayload = Infer +type LoginPayload = Infer +type ResetPasswordPayload = Infer + +/** + * Default role every self-registered account receives. Matches the + * `roleName` seeded by database/seeders/user_seeder.ts, so registration + * only works once that seed data exists, the same dependency the seeder + * itself documents. + */ +const DEFAULT_USER_ROLE = 'USER' + +/** + * NOTE: `password_reset_tokens.type` is not enumerated anywhere in the + * README beyond "VARCHAR(255) NOT NULL"; the PasswordResetToken model's + * own NOTE leaves the concrete values up to whichever branch owns the + * reset flow. A single literal value is used here since this branch has + * exactly one kind of reset token; `resetPassword` checks it defensively + * so a token row created for a different purpose in the future cannot be + * replayed against this endpoint. + */ +const PASSWORD_RESET_TOKEN_TYPE = 'password_reset' + +/** Activation tokens are valid for 24 hours from issuance. */ +const ACTIVATION_TOKEN_TTL_HOURS = 24 + +/** Password reset tokens are valid for 1 hour, shorter-lived than activation since they grant account takeover if leaked. */ +const PASSWORD_RESET_TOKEN_TTL_HOURS = 1 + +const E_ACCOUNT_NOT_ACTIVATED = createError( + 'Account is not activated', + 'E_ACCOUNT_NOT_ACTIVATED', + 403 +) +const E_ACCOUNT_LOCKED = createError('Account is locked', 'E_ACCOUNT_LOCKED', 403) +const E_EMAIL_TAKEN = createError('Email is already registered', 'E_EMAIL_TAKEN', 409) +const E_TOKEN_ALREADY_USED = createError('Token has already been used', 'E_TOKEN_ALREADY_USED', 400) +const E_TOKEN_EXPIRED = createError('Token has expired', 'E_TOKEN_EXPIRED', 400) +const E_INVALID_RESET_TOKEN = createError( + 'Invalid password reset token', + 'E_INVALID_RESET_TOKEN', + 400 +) + +/** + * Encapsulates the full account lifecycle: register -> activate -> login + * -> logout -> forgot password -> reset password -> me. Controllers never + * touch User, ActivationToken, BlacklistedToken or PasswordResetToken + * directly; every read or write to those models goes through this class. + * + * This service has no constructor dependencies (every collaborator is + * either a static Lucid model or a plain imported function), so it is not + * decorated with `@inject()`. The IoC container instantiates a class with + * no constructor arguments the same way `new AuthService()` would, and + * AuthController itself is the one that needs `@inject()` since it takes + * this service as a constructor argument. + */ +export default class AuthService { + /** + * Creates the user (password hashing happens in User's @beforeSave hook, + * never here), assigns the default USER role, issues an activation + * token, and emails it via ActivationMail. + * + * The raw activation token is no longer returned to the caller. A real + * mailer is wired up on this branch (SMTP against Mailhog), so echoing + * the token in the HTTP response would defeat the point of delivering it + * out of band: anyone who could read the API response could also + * activate the account, which is exactly the class of leak email + * delivery exists to close. The token is still persisted on + * ActivationToken exactly as before; only its delivery channel changed. + * + * The mail send happens after the transaction commits, not inside it. + * Sending mail is not transactional (there is no way to "roll back" an + * SMTP call), so keeping it outside the `db.transaction` block means a + * mail delivery failure can never mark an otherwise-successful + * registration as failed, and a transaction rollback (for example a + * unique-email race) can never result in an activation email for a user + * row that was never actually committed. + * + * All three writes run inside a single transaction. Without one, a + * crash or a thrown error between `User.create` and `ActivationToken.create` + * would leave a user row permanently stuck: disabled, no role attached, + * no activation token to ever enable it, and unrecoverable through the + * unique email constraint. A transaction makes the whole registration + * succeed or fail as one unit instead. + */ + async register(data: RegisterPayload): Promise { + const email = data.email.toLowerCase() + const userRole = await Role.findByOrFail('roleName', DEFAULT_USER_ROLE) + + // Pre-checked here rather than letting the migration's UNIQUE constraint + // reject the insert: a raw Postgres constraint error has no .status + // property, so app/exceptions/handler.ts's generic branch would turn a + // routine duplicate registration into an unhandled 500 instead of a + // clean 409. This is the same class of race UsersService.update already + // guards against, just at creation time instead of update time. + const existing = await User.findBy('email', email) + if (existing) { + throw new E_EMAIL_TAKEN() + } + + const { user, token } = await db.transaction(async (trx) => { + const newUser = await User.create( + { + firstName: data.firstName, + lastName: data.lastName, + email, + password: data.password, + enabled: false, + accountLocked: false, + }, + { client: trx } + ) + + await newUser.related('roles').attach([userRole.id]) + + const activationToken = await ActivationToken.create( + { + userId: newUser.id, + token: crypto.randomUUID(), + expiresAt: DateTime.now().plus({ hours: ACTIVATION_TOKEN_TTL_HOURS }), + }, + { client: trx } + ) + + return { user: newUser, token: activationToken.token } + }) + + // The user, role attachment and activation token are already committed + // at this point. A mail delivery failure (SMTP down, Mailhog + // unreachable) must not turn an otherwise-successful registration into + // a 500 the client would retry into an E_EMAIL_TAKEN conflict against + // an account they cannot yet activate. Logged and swallowed instead; + // the token is still in the database for a future resend flow to use. + try { + await mail.send(new ActivationMail(user, token)) + } catch (error) { + logger.error({ err: error, userId: user.id }, 'failed to send activation email') + } + + return user + } + + /** + * Validates an activation token (must exist, not already validated, not + * expired) and flips the account to enabled. + */ + async activate(token: string) { + const activationToken = await ActivationToken.query().where('token', token).firstOrFail() + + if (activationToken.validatedAt) { + throw new E_TOKEN_ALREADY_USED() + } + if (activationToken.expiresAt && activationToken.expiresAt < DateTime.now()) { + throw new E_TOKEN_EXPIRED() + } + + const user = await User.findOrFail(activationToken.userId) + user.enabled = true + await user.save() + + activationToken.validatedAt = DateTime.now() + await activationToken.save() + + return user + } + + /** + * Verifies the password with `hash.verify()`, then checks `enabled` and + * `accountLocked`, in that order, matching the README's task + * description. A missing user and a wrong password both raise the same + * `E_INVALID_CREDENTIALS` (400) so the response never confirms whether + * an email address is registered. + */ + async login(data: LoginPayload) { + const user = await User.query().where('email', data.email.toLowerCase()).first() + + if (!user || !user.password || !(await hash.verify(user.password, data.password))) { + throw new authErrors.E_INVALID_CREDENTIALS('Invalid email or password') + } + + if (!user.enabled) { + throw new E_ACCOUNT_NOT_ACTIVATED() + } + if (user.accountLocked) { + throw new E_ACCOUNT_LOCKED() + } + + const token = await signJwt(user) + return { user, token } + } + + /** + * Blacklists the JWT presented for this request by writing its `jti` + * (and the raw token, required by the NOT NULL `token` column) into + * `blacklisted_tokens`. AuthMiddleware rejects any future request + * bearing this `jti` regardless of the token's own expiry. + * + * NOTE: `blacklisted_tokens.validated_at` mirrors ActivationToken's + * column shape but has no meaning for a blacklist entry (a blacklisted + * token is never "validated"), so it is left null here. + */ + async logout(user: User, payload: JwtPayload, rawToken: string) { + await BlacklistedToken.create({ + userId: user.id, + token: rawToken, + jti: payload.jti, + blacklistedAt: DateTime.now(), + expiresAt: DateTime.fromSeconds(payload.exp), + }) + } + + /** + * Issues a fresh JWT for an already-authenticated user. + * + * NOTE: not explicitly listed among AuthService's methods in the + * README's task checklist, only the `refresh` endpoint's row in the + * Endpoints table ("Issue new JWT (if not blacklisted)"). The + * "if not blacklisted" precondition is enforced by AuthMiddleware before + * this ever runs (the route requires `middleware.auth()`), so this + * method's own job is just re-checking enabled/accountLocked, the same + * guard `login` applies, since an account can be locked after its + * original token was issued. This does not blacklist the token being + * refreshed; the README does not ask for rotation, only reissuance. + */ + async refresh(user: User) { + if (!user.enabled) { + throw new E_ACCOUNT_NOT_ACTIVATED() + } + if (user.accountLocked) { + throw new E_ACCOUNT_LOCKED() + } + + return signJwt(user) + } + + /** + * Issues a PasswordResetToken and emails it via PasswordResetMail. As + * with `register`'s activation token, the raw value is no longer + * returned to the caller now that a real mailer is wired up; the token + * is still persisted on PasswordResetToken exactly as before, only its + * delivery channel and this method's return value changed. Returning + * void rather than the user keeps the controller from having anything + * meaningful to echo back beyond a confirmation message. + * + * NOTE: a production system would return a generic success response + * regardless of whether the email exists, to avoid leaking which + * addresses are registered. This tutorial uses `findByOrFail` (404 on + * an unknown email) instead, favouring explicit, testable behaviour + * over that hardening, consistent with the rest of this branch's scope. + */ + async forgotPassword(email: string): Promise { + const user = await User.findByOrFail('email', email.toLowerCase()) + + const resetToken = await PasswordResetToken.create({ + userId: user.id, + token: crypto.randomUUID(), + type: PASSWORD_RESET_TOKEN_TYPE, + expiryDate: DateTime.now().plus({ hours: PASSWORD_RESET_TOKEN_TTL_HOURS }), + }) + + // Same reasoning as register(): the reset token is already committed, + // so a mail delivery failure here must not surface as a 500 for a + // request that otherwise succeeded. + try { + await mail.send(new PasswordResetMail(user, resetToken.token)) + } catch (error) { + logger.error({ err: error, userId: user.id }, 'failed to send password reset email') + } + } + + /** + * Validates the token's type and expiry, updates the password (hashed + * again by User's @beforeSave hook, not here), then deletes the token + * so it cannot be replayed. + */ + async resetPassword(data: ResetPasswordPayload) { + const resetToken = await PasswordResetToken.query().where('token', data.token).firstOrFail() + + if (resetToken.type !== PASSWORD_RESET_TOKEN_TYPE) { + throw new E_INVALID_RESET_TOKEN() + } + if (resetToken.expiryDate < DateTime.now()) { + throw new E_TOKEN_EXPIRED() + } + + const user = await User.findOrFail(resetToken.userId) + user.password = data.password + await user.save() + + await resetToken.delete() + + return user + } + + /** + * Returns the authenticated user with `roles` and, for each role, its + * `permissions` preloaded, per the README's task description ("preloaded + * roles and permissions"). `auth.user` never auto-loads relations (the + * same caveat AuthMiddleware and RoleMiddleware work around), so this + * explicit preload is required here too. + */ + async me(user: User) { + await user.load('roles', (rolesQuery) => { + rolesQuery.preload('permissions') + }) + + return user + } +} diff --git a/app/services/permissions_service.ts b/app/services/permissions_service.ts new file mode 100644 index 0000000..cdf76e8 --- /dev/null +++ b/app/services/permissions_service.ts @@ -0,0 +1,70 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import Permission from '#models/permission' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { + createPermissionSchema, + updatePermissionSchema, +} from '#validators/permission_validator' + +type CreatePermissionPayload = Infer +type UpdatePermissionPayload = Infer + +const E_PERMISSION_IN_USE = createError( + 'Permission is still attached to at least one role', + 'E_PERMISSION_IN_USE', + 409 +) + +/** + * Owns every read and write to the `permissions` table from the + * ADMIN-only administration endpoints. No constructor dependencies, so no + * @inject(), matching AuthService, UsersService and RolesService's own + * reasoning for the same omission. + */ +export default class PermissionsService { + async findAll(page: number, limit: number): Promise> { + const paginator = await Permission.query().paginate(page, limit) + return toPageResponse(paginator) + } + + /** + * Not exposed by any route (the README's Endpoints table has no `GET + * /api/v1/permissions/:id` row, unlike users and roles), but still + * needed internally by `update` to fetch the record before mutating it, + * and listed explicitly in the README's own task checklist for this + * service. + */ + async findOne(id: number): Promise { + return Permission.findOrFail(id) + } + + async create(data: CreatePermissionPayload): Promise { + return Permission.create(data) + } + + async update(id: number, data: UpdatePermissionPayload): Promise { + const permission = await Permission.findOrFail(id) + permission.merge(data) + await permission.save() + return permission + } + + /** + * Business rule from the README: a permission still attached to at + * least one role cannot be deleted, 409 instead. Same "stop at the + * first match" reasoning as RolesService.remove's own check against + * `role_user`. + */ + async remove(id: number): Promise { + const permission = await Permission.findOrFail(id) + + const stillAttached = await permission.related('roles').query().first() + if (stillAttached) { + throw new E_PERMISSION_IN_USE() + } + + await permission.delete() + } +} diff --git a/app/services/roles_service.ts b/app/services/roles_service.ts new file mode 100644 index 0000000..34457ce --- /dev/null +++ b/app/services/roles_service.ts @@ -0,0 +1,120 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import Role from '#models/role' +import Permission from '#models/permission' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { createRoleSchema, updateRoleSchema } from '#validators/role_validator' + +type CreateRolePayload = Infer +type UpdateRolePayload = Infer + +const E_ROLE_IN_USE = createError( + 'Role is still assigned to at least one user', + 'E_ROLE_IN_USE', + 409 +) +const E_ROLE_NAME_TAKEN = createError('Role name is already in use', 'E_ROLE_NAME_TAKEN', 409) + +/** + * Owns every read and write to the `roles` table and the `role_user` / + * `role_permission` pivots from the ADMIN-only administration endpoints. + * No constructor dependencies, so no @inject(), matching AuthService and + * UsersService's own reasoning for the same omission. + */ +export default class RolesService { + async findAll(page: number, limit: number): Promise> { + const paginator = await Role.query().paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + return Role.query().where('id', id).preload('permissions').firstOrFail() + } + + /** + * Pre-checks roleName uniqueness rather than letting the migration's + * UNIQUE constraint reject the insert: a raw Postgres constraint error has + * no .status property, so app/exceptions/handler.ts's generic branch + * would turn a routine duplicate-name conflict into an unhandled 500 + * instead of a clean 409, the same class of gap UsersService.update + * already guards against for email. + */ + async create(data: CreateRolePayload): Promise { + const existing = await Role.findBy('roleName', data.roleName) + if (existing) { + throw new E_ROLE_NAME_TAKEN() + } + return Role.create(data) + } + + async update(id: number, data: UpdateRolePayload): Promise { + const role = await Role.findOrFail(id) + + if (data.roleName && data.roleName !== role.roleName) { + const existing = await Role.query() + .where('roleName', data.roleName) + .whereNot('id', id) + .first() + if (existing) { + throw new E_ROLE_NAME_TAKEN() + } + } + + role.merge(data) + await role.save() + return role + } + + /** + * Business rule from the README: a role with at least one user still + * assigned cannot be deleted, 409 instead. Checked by querying the + * `role_user` pivot through the relation rather than counting every row + * (`related('users').query().first()` stops at the first match), since + * the only thing that matters here is "does at least one exist", not + * how many. + */ + async remove(id: number): Promise { + const role = await Role.findOrFail(id) + + const stillAssigned = await role.related('users').query().first() + if (stillAssigned) { + throw new E_ROLE_IN_USE() + } + + await role.delete() + } + + /** Idempotent, same reasoning as UsersService.assignRole. */ + async assignPermission(id: number, permissionId: number): Promise { + const role = await Role.findOrFail(id) + const permission = await Permission.findOrFail(permissionId) + + await role.load('permissions') + const alreadyAssigned = role.permissions.some((p) => p.id === permission.id) + if (!alreadyAssigned) { + await role.related('permissions').attach([permission.id]) + role.permissions.push(permission) + } + + return role + } + + /** Idempotent, same reasoning as UsersService.revokeRole. */ + async revokePermission(id: number, permissionId: number): Promise { + const role = await Role.findOrFail(id) + await role.load('permissions') + await role.related('permissions').detach([permissionId]) + + // role.permissions is Lucid's opaque ManyToMany at + // the type level, array-like at runtime but not reassignable, so the + // detached permission is spliced out in place rather than filtered + // into a new array. + const index = role.permissions.findIndex((permission) => permission.id === permissionId) + if (index !== -1) { + role.permissions.splice(index, 1) + } + + return role + } +} diff --git a/app/services/users_service.ts b/app/services/users_service.ts new file mode 100644 index 0000000..ac24da2 --- /dev/null +++ b/app/services/users_service.ts @@ -0,0 +1,121 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import User from '#models/user' +import Role from '#models/role' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { updateUserSchema } from '#validators/user_validator' + +type UpdateUserPayload = Infer + +const E_EMAIL_TAKEN = createError('Email is already in use by another user', 'E_EMAIL_TAKEN', 409) + +/** + * Owns every read and write to the `users` table from the administration + * endpoints. This is deliberately separate from AuthService: AuthService + * covers the self-service account lifecycle (register/activate/login/...) + * a user runs against their own account, while UsersService covers an + * ADMIN acting on someone else's account (list, edit, delete, change + * roles). Splitting them keeps AuthService's own scope from growing to + * cover both "what a user can do to themselves" and "what an ADMIN can do + * to anyone", which are different authorization stories even though both + * ultimately touch the same User model. + * + * No constructor dependencies, so no @inject() here, the same reasoning + * AuthService documents for itself: every collaborator is a static Lucid + * model, and UsersController is the class that actually needs @inject() + * since it takes this service as a constructor argument. + */ +export default class UsersService { + async findAll(page: number, limit: number): Promise> { + const paginator = await User.query().paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + return User.query().where('id', id).preload('roles').firstOrFail() + } + + /** + * Only re-checks email uniqueness when `email` is present in the payload + * and actually differs from the user's current value; updating a user + * without touching their email (for example flipping `accountLocked`) + * should never trip a false conflict against the user's own row. + */ + async update(id: number, data: UpdateUserPayload): Promise { + const user = await User.findOrFail(id) + + if (data.email) { + const email = data.email.toLowerCase() + if (email !== user.email) { + const existing = await User.query().where('email', email).whereNot('id', id).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } + } + data = { ...data, email } + } + + user.merge(data) + await user.save() + return user + } + + async remove(id: number): Promise { + const user = await User.findOrFail(id) + await user.delete() + } + + /** + * Idempotent: attaching a role the user already has is a no-op rather + * than a duplicate pivot row or a thrown error. `findOrFail` on both + * User and Role naturally produces the "404 if user or role does not + * exist" behaviour the README asks for, no separate existence check + * needed. + */ + async assignRole(id: number, roleId: number): Promise { + const user = await User.findOrFail(id) + const role = await Role.findOrFail(roleId) + + await user.load('roles') + const alreadyAssigned = user.roles.some((r) => r.id === role.id) + if (!alreadyAssigned) { + await user.related('roles').attach([role.id]) + // attach() only adds the one pivot row already known here, so the + // freshly loaded roles array plus this role is exactly what a reload + // would return, without a second round trip to prove it. + user.roles.push(role) + } + + return user + } + + /** + * Idempotent in the other direction: detaching a role the user never + * had, or a roleId that does not even exist as a row, is a no-op. + * Lucid's `detach` deletes at most one pivot row and never errors when + * none matches, so there is nothing extra to guard here. + * + * NOTE: unlike `assignRole`, this method does not `findOrFail` the role + * itself. The README only specifies "404 if user or role does not + * exist" for assign; revoke's own spec is "idempotent, no error if the + * role was not assigned", which by construction also covers a roleId + * that never existed at all, so validating the user's own existence is + * enough here. + */ + async revokeRole(id: number, roleId: number): Promise { + const user = await User.findOrFail(id) + await user.load('roles') + await user.related('roles').detach([roleId]) + + // user.roles is Lucid's opaque ManyToMany at the type + // level, array-like at runtime but not reassignable, so the detached + // role is spliced out in place rather than filtered into a new array. + const index = user.roles.findIndex((role) => role.id === roleId) + if (index !== -1) { + user.roles.splice(index, 1) + } + + return user + } +} diff --git a/app/validators/auth_validator.ts b/app/validators/auth_validator.ts new file mode 100644 index 0000000..d73d90f --- /dev/null +++ b/app/validators/auth_validator.ts @@ -0,0 +1,97 @@ +import vine from '@vinejs/vine' + +/** + * Password complexity rule shared by registration and password reset. + * + * The README only specifies `minLength(8)` plus "a complexity regex" + * without naming the exact rule, so this project settles on the common + * baseline of at least one lowercase letter, one uppercase letter and one + * digit. `minLength(8)` is still applied separately below since this + * regex alone would also accept a 3-character password. + * + * NOTE: this decision (the exact complexity rule) is not spelled out in + * the README and is left to this validator's judgment. + * + * `maxLength(128)` on every password field below is a deliberate cap, not + * an arbitrary one: config/hash.ts hashes with Argon2id, whose cost scales + * with input size, and the register/reset-password endpoints are public. + * Without a bound, a client could submit a multi-megabyte password field + * (config/bodyparser.ts allows large JSON bodies) and force expensive + * hashing work per request, a cost-amplification vector authThrottle's + * request-count limit alone does not prevent. + */ +const passwordComplexity = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/ + +/** + * POST /api/v1/auth/register + * + * Field lengths mirror the `users` table constraints from the README's + * Auth Model section (first_name VARCHAR(50), last_name VARCHAR(100), + * email VARCHAR(100)) so a payload that would violate the DB column length + * is rejected as a 422 validation error instead of a raw DB error. + */ +export const registerSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(50), + lastName: vine.string().trim().minLength(1).maxLength(100), + email: vine.string().trim().maxLength(100).email(), + password: vine.string().minLength(8).maxLength(128).regex(passwordComplexity), + }) +) + +/** + * POST /api/v1/auth/login + * + * The password field intentionally has no minLength/complexity check here: + * login validates a credential against whatever is already stored, so + * rejecting a short password before it even reaches AuthService.login + * would turn a "wrong password" case into a confusing 422 instead of the + * expected 400 E_INVALID_CREDENTIALS. + */ +export const loginSchema = vine.compile( + vine.object({ + email: vine.string().trim().email(), + password: vine.string().maxLength(128), + }) +) + +/** + * POST /api/v1/auth/activate + * + * NOTE: the README does not spell out this schema's shape; it only lists + * `registerSchema`, `loginSchema` and `resetPasswordSchema` explicitly and + * leaves the rest to "whatever additional schemas the other AuthController + * actions genuinely need". Activation is a single opaque token round trip, + * so a bare token string is all that is required. + */ +export const activateSchema = vine.compile( + vine.object({ + token: vine.string().trim().minLength(1), + }) +) + +/** + * POST /api/v1/auth/forgot-password + * + * NOTE: same as above, not enumerated in the README. Only an email is + * needed to look up the account and issue a PasswordResetToken. + */ +export const forgotPasswordSchema = vine.compile( + vine.object({ + email: vine.string().trim().email(), + }) +) + +/** + * POST /api/v1/auth/reset-password + * + * Reuses the same complexity rule as registration since both operations + * end with the same @beforeSave hashing hook on User and should not allow + * a weaker password through the back door of the reset flow. + */ +export const resetPasswordSchema = vine.compile( + vine.object({ + token: vine.string().trim().minLength(1), + password: vine.string().minLength(8).maxLength(128).regex(passwordComplexity), + }) +) diff --git a/app/validators/pagination_validator.ts b/app/validators/pagination_validator.ts new file mode 100644 index 0000000..295a7e2 --- /dev/null +++ b/app/validators/pagination_validator.ts @@ -0,0 +1,17 @@ +import vine from '@vinejs/vine' + +/** + * Shared by every paginated list endpoint (users, roles, permissions here, + * categories/products/customers/orders in later branches). Replaces a bare + * `Number(request.input('page', 1))` in each controller, which let a + * non-numeric value flow through as NaN straight into Lucid's `paginate()` + * and had no upper bound on `limit`, letting a client request an entire + * table in one response, exactly what the project's "no unbounded queries" + * rule exists to prevent. + */ +export const paginationSchema = vine.compile( + vine.object({ + page: vine.number().positive().optional(), + limit: vine.number().positive().max(100).optional(), + }) +) diff --git a/app/validators/permission_validator.ts b/app/validators/permission_validator.ts new file mode 100644 index 0000000..63e334b --- /dev/null +++ b/app/validators/permission_validator.ts @@ -0,0 +1,28 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/permissions + * + * `resource` and `action` mirror the `permissions` table's own columns + * (`VARCHAR(50) NOT NULL` each) from the README's Auth Model section. A + * permission is the pair of the two, for example `resource: 'categories'`, + * `action: 'delete'`, so both are required on create. + */ +export const createPermissionSchema = vine.compile( + vine.object({ + resource: vine.string().trim().minLength(1).maxLength(50), + action: vine.string().trim().minLength(1).maxLength(50), + }) +) + +/** + * PUT /api/v1/permissions/:id + * + * Both fields optional since this is a partial update. + */ +export const updatePermissionSchema = vine.compile( + vine.object({ + resource: vine.string().trim().minLength(1).maxLength(50).optional(), + action: vine.string().trim().minLength(1).maxLength(50).optional(), + }) +) diff --git a/app/validators/role_validator.ts b/app/validators/role_validator.ts new file mode 100644 index 0000000..4d0d3fa --- /dev/null +++ b/app/validators/role_validator.ts @@ -0,0 +1,45 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/roles + * + * `roleName` mirrors the `roles.role_name VARCHAR(50) NOT NULL, UNIQUE` + * column from the README's Auth Model section. Uniqueness itself is + * enforced by the database's own unique index; RolesService does not + * duplicate that check at the service layer the way UsersService does for + * email, since a race there would only ever surface as a clean 500 turned + * into a DB constraint error, not a business rule this branch was asked to + * cover explicitly. + */ +export const createRoleSchema = vine.compile( + vine.object({ + roleName: vine.string().trim().minLength(1).maxLength(50), + }) +) + +/** + * PUT /api/v1/roles/:id + * + * Optional since this is a partial update, though in practice `roleName` + * is the only field a role has to update. + */ +export const updateRoleSchema = vine.compile( + vine.object({ + roleName: vine.string().trim().minLength(1).maxLength(50).optional(), + }) +) + +/** + * POST /api/v1/roles/:id/permissions + * + * NOTE: same reasoning as `assignRoleSchema` in user_validator.ts, the + * README names `createRoleSchema` and `updateRoleSchema` as this file's + * schemas but does not name one for the permission-assignment request + * body. It lives here because the endpoint is declared under the `/roles` + * resource and owned by RolesController. + */ +export const assignPermissionSchema = vine.compile( + vine.object({ + permissionId: vine.number().positive(), + }) +) diff --git a/app/validators/user_validator.ts b/app/validators/user_validator.ts new file mode 100644 index 0000000..f02b987 --- /dev/null +++ b/app/validators/user_validator.ts @@ -0,0 +1,42 @@ +import vine from '@vinejs/vine' + +/** + * PUT /api/v1/users/:id + * + * Every field is optional since this is a partial update: an ADMIN caller + * may want to change only `enabled` (to disable an account) or only + * `accountLocked` (to lock it) without having to resend the rest of the + * record. `email`, `firstName` and `lastName` reuse the same length limits + * as `registerSchema` in auth_validator.ts, since they map to the exact + * same `users` columns. There is no `password` field here on purpose: this + * is an administration endpoint for editing account metadata, not a way + * for an ADMIN to set another user's password, that would bypass the + * reset-password flow's own token verification entirely. + */ +export const updateUserSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(50).optional(), + lastName: vine.string().trim().minLength(1).maxLength(100).optional(), + email: vine.string().trim().maxLength(100).email().optional(), + enabled: vine.boolean().optional(), + accountLocked: vine.boolean().optional(), + }) +) + +/** + * POST /api/v1/users/:id/roles + * + * NOTE: the README enumerates `updateUserSchema` as the only schema owned + * by this file; it does not name a schema for the role-assignment request + * body. `POST /users/:id/roles` still needs to read a `roleId` from + * somewhere, and per this project's hard rule against inline VineJS + * schemas in controllers, that body needs a compiled schema of its own. + * It lives here rather than in role_validator.ts because the endpoint is + * declared under the `/users` resource, mirroring how UsersController (not + * RolesController) owns the assign/revoke actions. + */ +export const assignRoleSchema = vine.compile( + vine.object({ + roleId: vine.number().positive(), + }) +) diff --git a/config/auth.ts b/config/auth.ts index c3a4707..54219ac 100644 --- a/config/auth.ts +++ b/config/auth.ts @@ -1,34 +1,19 @@ import { defineConfig } from '@adonisjs/auth' -import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' +import { jwtGuard } from '#auth/jwt_guard' import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' -// NOTE: the stateless JWT guard described in the README belongs to -// feature/auth. This branch only needs the User model to resolve through -// an auth guard well enough for the app to boot and typecheck; the -// tokens guard from the default --kit=api scaffold required an -// accessTokens provider on User that the EER_AUTH design does not use, -// so it is dropped here rather than kept alongside a model that no -// longer supports it. feature/auth replaces this guard with the JWT one. const authConfig = defineConfig({ /** * Default guard used when no guard is explicitly specified. */ - default: 'web', + default: 'jwt', guards: { /** - * Session-based guard for browser authentication. + * Stateless guard backed by the custom JwtGuard in app/auth/jwt_guard.ts, + * the only guard this project authenticates requests with. */ - web: sessionGuard({ - /** - * Enable persistent login using remember-me tokens. - */ - useRememberMeTokens: false, - - provider: sessionUserProvider({ - model: () => import('#models/user'), - }), - }), + jwt: jwtGuard(), }, }) diff --git a/config/hash.ts b/config/hash.ts index a54c086..566d381 100644 --- a/config/hash.ts +++ b/config/hash.ts @@ -1,65 +1,36 @@ +import env from '#start/env' import { defineConfig, drivers } from '@adonisjs/core/hash' /** * Hashing configuration. * - * This starter uses Node.js scrypt under the hood. - * Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback + * The default hasher is read from HASH_DRIVER so an environment can switch + * between argon and bcrypt without a code change, matching the enum already + * declared in start/env.ts. Argon2 is the recommended choice and what every + * documented env file in this project sets HASH_DRIVER to; bcrypt is kept + * configured as a documented fallback rather than removed outright. */ const hashConfig = defineConfig({ - /** - * Default hasher used by the application. - */ - default: 'scrypt', + default: env.get('HASH_DRIVER'), list: { /** - * Scrypt is memory-hard, which makes brute-force attacks more expensive. + * Argon2id, the variant OWASP recommends when side channel attacks are + * a concern alongside GPU cracking, which fits a JWT backed API that + * only ever hashes on register, activation and password reset. */ - scrypt: drivers.scrypt({ - /** - * Work factor (Node alias: N / cost). - * Higher values increase security and CPU+memory usage. - * - * Tuning guideline: - * - Start with 16384. - * - Increase gradually (for example 32768) and benchmark login/signup latency. - * - Keep values practical for your slowest production machine. - * - * Node constraint: value must be a power of two greater than 1. - */ - cost: 16384, - - /** - * Block size (Node alias: r / blockSize). - * Increases memory and CPU linearly. - * - * Tuning guideline: - * - Keep 8 unless you have a measured reason to change it. - * - Raise only with benchmark data, because memory usage grows quickly. - */ - blockSize: 8, - - /** - * Parallelization (Node alias: p / parallelization). - * Controls how many independent computations are performed. - * - * Tuning guideline: - * - Keep 1 for most applications. - * - Increase only after load testing if your infrastructure benefits from it. - */ - parallelization: 1, + argon: drivers.argon2({ + variant: 'id', + memory: 65536, + iterations: 3, + parallelism: 4, + }), - /** - * Maximum memory limit in bytes (Node alias: maxmem / maxMemory). - * Hashing throws if the estimated memory usage is above this limit. - * Node documents the check as approximately: 128 * N * r > maxmem. - * - * Tuning guideline: - * - Keep this aligned with your cost/blockSize choices. - * - Increase carefully on memory-constrained environments. - */ - maxMemory: 33554432, + /** + * Bcrypt, kept as a documented alternative. + */ + bcrypt: drivers.bcrypt({ + rounds: 10, }), }, }) diff --git a/config/limiter.ts b/config/limiter.ts new file mode 100644 index 0000000..bb1f0a3 --- /dev/null +++ b/config/limiter.ts @@ -0,0 +1,32 @@ +import { defineConfig, stores } from '@adonisjs/limiter' + +// NOTE: LIMITER_STORE is typed as 'database' | 'redis' in start/env.ts, +// matching the README's documented env reference, but only the database +// store is wired up below since this project has no Redis instance. The +// default is hardcoded to 'database' rather than read from env, the same +// way config/database.ts hardcodes its pg connection instead of deriving +// it from a variable with no matching configured alternative. +const limiterConfig = defineConfig({ + default: 'database', + stores: { + /** + * Database store to save rate limiting data inside a + * MYSQL or PostgreSQL database. + */ + database: stores.database({ + tableName: 'rate_limits', + }), + + /** + * Memory store could be used during + * testing + */ + memory: stores.memory({}), + }, +}) + +export default limiterConfig + +declare module '@adonisjs/limiter/types' { + export interface LimitersList extends InferLimiters {} +} diff --git a/config/mail.ts b/config/mail.ts new file mode 100644 index 0000000..022fdb7 --- /dev/null +++ b/config/mail.ts @@ -0,0 +1,36 @@ +import env from '#start/env' +import { defineConfig, transports } from '@adonisjs/mail' + +/** + * Mail bodies in this project are built as plain strings inside the mailer + * classes themselves (see app/mails/), not rendered from Edge templates. + * This project has no view provider configured anywhere, it is an API only + * service per the README's own architecture, so there is no globals block + * here for template variables the way the package's own scaffold suggests. + */ +const mailConfig = defineConfig({ + default: env.get('MAIL_MAILER'), + + from: { + address: env.get('MAIL_FROM_ADDRESS'), + name: env.get('MAIL_FROM_NAME'), + }, + + /** + * SMTP pointed at Mailhog in every environment, development and test + * alike, so registration and password reset emails are always visible in + * Mailhog's web UI instead of attempting real delivery. + */ + mailers: { + smtp: transports.smtp({ + host: env.get('SMTP_HOST'), + port: env.get('SMTP_PORT'), + }), + }, +}) + +export default mailConfig + +declare module '@adonisjs/mail/types' { + export interface MailersList extends InferMailers {} +} diff --git a/database/migrations/1787564106653_create_rate_limits_table.ts b/database/migrations/1787564106653_create_rate_limits_table.ts new file mode 100644 index 0000000..3baffd1 --- /dev/null +++ b/database/migrations/1787564106653_create_rate_limits_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'rate_limits' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.string('key', 255).notNullable().primary() + table.integer('points', 9).notNullable().defaultTo(0) + table.bigint('expire').unsigned() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/schema.ts b/database/schema.ts index 76665a1..ee8acf2 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -125,6 +125,17 @@ export class ProductSchema extends BaseModel { declare unitPrice: number } +export class RateLimitSchema extends BaseModel { + static $columns = ['expire', 'key', 'points'] as const + $columns = RateLimitSchema.$columns + @column() + declare expire: bigint | number | null + @column({ isPrimary: true }) + declare key: string + @column() + declare points: number +} + export class RolePermissionSchema extends BaseModel { static $columns = ['permissionId', 'roleId'] as const $columns = RolePermissionSchema.$columns diff --git a/docker-compose.test.yml b/docker-compose.test.yml index fab2c4a..6109c3b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -7,14 +7,21 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: adonisjs_tutorial_test ports: - - "5433:5432" + - '5433:5432' volumes: - postgres_test_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 10s timeout: 5s retries: 5 + mailhog_test: + image: mailhog/mailhog + restart: unless-stopped + ports: + - '1026:1025' + - '8026:8025' + volumes: postgres_test_data: diff --git a/docker-compose.yml b/docker-compose.yml index df2808b..04cb8d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,14 +7,21 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: adonisjs_tutorial ports: - - "5432:5432" + - '5432:5432' volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 10s timeout: 5s retries: 5 + mailhog: + image: mailhog/mailhog + restart: unless-stopped + ports: + - '1025:1025' + - '8025:8025' + volumes: postgres_data: diff --git a/package-lock.json b/package-lock.json index 9a92682..18bc0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,20 @@ "license": "MIT", "dependencies": { "@adonisjs/auth": "^10.1.0", + "@adonisjs/bouncer": "^4.0.1", "@adonisjs/core": "^7.4.0", "@adonisjs/cors": "^3.0.0", + "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", + "@adonisjs/mail": "^10.4.0", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", "@japa/api-client": "^3.2.1", "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", + "argon2": "^0.45.1", "better-sqlite3": "^13.0.3", + "jose": "^6.2.10", "luxon": "^3.7.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2" @@ -197,6 +202,35 @@ "@adonisjs/http-server": "^8.0.0-next.17 || ^8.0.0 || ^9.0.0" } }, + "node_modules/@adonisjs/bouncer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adonisjs/bouncer/-/bouncer-4.0.1.tgz", + "integrity": "sha512-YTVlr2apjEHZIDgs09zl2dAMse5xBOCWQ+iY5qYxsZwMRAZcNHaOMjhD6LGjxXKyyM0tFbypgshAWiKuFbI1MA==", + "license": "MIT", + "dependencies": { + "@poppinss/utils": "^7.0.1" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.26 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.16 || ^7.0.0", + "@adonisjs/i18n": "^3.0.0-next.2 || ^3.0.0", + "edge.js": "^6.4.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@adonisjs/i18n": { + "optional": true + }, + "edge.js": { + "optional": true + } + } + }, "node_modules/@adonisjs/config": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-6.1.0.tgz", @@ -502,6 +536,35 @@ "@adonisjs/fold": "^11.0.0-next.2" } }, + "node_modules/@adonisjs/limiter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@adonisjs/limiter/-/limiter-3.0.1.tgz", + "integrity": "sha512-U7drt/FPlB5PV84dnxQQSFGk75fDxgEHSkfT/eGwx/+Z4re7XlkVU4oros/VGefD72JTgXp7Kz97tS1XW7eEXA==", + "dependencies": { + "rate-limiter-flexible": "^9.1.1" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.9 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.1 || ^7.0.0", + "@adonisjs/i18n": "^3.0.0-next.0 || ^3.0.0", + "@adonisjs/lucid": "^22.0.0-next.0 || ^22.0.0", + "@adonisjs/redis": "^10.0.0-next.1 || ^10.0.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@adonisjs/i18n": { + "optional": true + }, + "@adonisjs/lucid": { + "optional": true + }, + "@adonisjs/redis": { + "optional": true + } + } + }, "node_modules/@adonisjs/logger": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@adonisjs/logger/-/logger-7.1.1.tgz", @@ -567,6 +630,57 @@ } } }, + "node_modules/@adonisjs/mail": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@adonisjs/mail/-/mail-10.4.0.tgz", + "integrity": "sha512-+o5qYB26gJgOGQysR/1KVEziM+MhSX9uP5ZBZjQuPC2xPTfvL5k10jHFQe5fZimhRHnETtNhoi4h3KIlen4KmQ==", + "license": "MIT", + "dependencies": { + "@poppinss/macroable": "^1.1.2", + "@poppinss/object-builder": "^1.1.0", + "@types/nodemailer": "^8.0.1", + "fastq": "^1.20.1", + "ical-generator": "^11.0.0", + "ky": "^2.0.2", + "nodemailer": "^9.0.3" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@adonisjs/assembler": "^8.0.0-next.14 || ^8.0.0", + "@adonisjs/core": "^7.0.0-next.8 || ^7.0.0", + "@aws-sdk/client-sesv2": "^3.997.0", + "edge.js": "^6.0.1", + "mjml": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@adonisjs/assembler": { + "optional": true + }, + "@aws-sdk/client-sesv2": { + "optional": true + }, + "edge.js": { + "optional": true + }, + "mjml": { + "optional": true + } + } + }, + "node_modules/@adonisjs/mail/node_modules/ky": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", + "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/@adonisjs/presets": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@adonisjs/presets/-/presets-3.0.0.tgz", @@ -952,6 +1066,12 @@ "node": ">=0.1.90" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -2028,7 +2148,7 @@ "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/methods": { @@ -2046,6 +2166,15 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -2466,6 +2595,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3038,11 +3183,27 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3807,7 +3968,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4312,6 +4472,54 @@ "node": ">=18.18.0" } }, + "node_modules/ical-generator": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/ical-generator/-/ical-generator-11.1.0.tgz", + "integrity": "sha512-/CSyXcelySK5qYAUxXnwzhAi1UPVnapnVMXqkRQ+L0RXHCFtzqLm92o5PD0ZRhaWBCtkS0hP8YIxMrSRZ+8Zug==", + "license": "MIT", + "engines": { + "node": "22 || >=24.0.0" + }, + "peerDependencies": { + "@touch4it/ical-timezones": ">=1.6.0", + "@types/luxon": ">= 1.26.0", + "@types/mocha": ">= 8.2.1", + "dayjs": ">= 1.10.0", + "luxon": ">= 1.26.0", + "moment": ">= 2.29.0", + "moment-timezone": ">= 0.5.33", + "rrule": ">= 2.6.8" + }, + "peerDependenciesMeta": { + "@touch4it/ical-timezones": { + "optional": true + }, + "@types/luxon": { + "optional": true + }, + "@types/mocha": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-timezone": { + "optional": true + }, + "rrule": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -4631,7 +4839,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, "node_modules/jest-diff": { @@ -4649,6 +4856,15 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -5195,6 +5411,17 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -5205,6 +5432,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", @@ -5484,7 +5720,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5959,6 +6194,12 @@ "node": ">= 0.8" } }, + "node_modules/rate-limiter-flexible": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-9.1.1.tgz", + "integrity": "sha512-imxFjzPCmvDLMe7d2tsgiSQvs5EI2fI9SNymmslAfOqznZhsZ+PqbIjIYKpuSbd3pKovR1aMG47qfCLIO/adVg==", + "license": "ISC" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -6172,7 +6413,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -6289,7 +6529,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6302,7 +6541,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -7088,7 +7326,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index f04437f..f2c8ff9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "imports": { "#controllers/*": "./app/controllers/*.js", + "#auth/*": "./app/auth/*.js", "#helpers/*": "./app/helpers/*.js", "#exceptions/*": "./app/exceptions/*.js", "#models/*": "./app/models/*.js", @@ -59,15 +60,20 @@ }, "dependencies": { "@adonisjs/auth": "^10.1.0", + "@adonisjs/bouncer": "^4.0.1", "@adonisjs/core": "^7.4.0", "@adonisjs/cors": "^3.0.0", + "@adonisjs/limiter": "^3.0.1", "@adonisjs/lucid": "^22.4.2", + "@adonisjs/mail": "^10.4.0", "@adonisjs/session": "^8.1.0", "@adonisjs/shield": "^9.0.0", "@japa/api-client": "^3.2.1", "@tuyau/core": "^1.2.2", "@vinejs/vine": "^4.4.0", + "argon2": "^0.45.1", "better-sqlite3": "^13.0.3", + "jose": "^6.2.10", "luxon": "^3.7.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2" @@ -80,6 +86,7 @@ }, "allowScripts": { "@swc/core": true, + "argon2": true, "better-sqlite3": true, "esbuild": true, "fsevents": true diff --git a/start/env.ts b/start/env.ts index 3463eb7..6365387 100644 --- a/start/env.ts +++ b/start/env.ts @@ -19,7 +19,7 @@ export default await Env.create(new URL('../', import.meta.url), { LOG_LEVEL: Env.schema.string(), // App - APP_KEY: Env.schema.string(), + APP_KEY: Env.schema.secret(), APP_URL: Env.schema.string({ format: 'url', tld: false }), // Session @@ -33,7 +33,7 @@ export default await Env.create(new URL('../', import.meta.url), { DB_DATABASE: Env.schema.string(), // Auth - JWT_SECRET: Env.schema.string(), + JWT_SECRET: Env.schema.secret(), JWT_EXPIRY: Env.schema.string(), // Hashing @@ -53,4 +53,11 @@ export default await Env.create(new URL('../', import.meta.url), { TEST_ADMIN_PASSWORD: Env.schema.string.optional(), TEST_USER_EMAIL: Env.schema.string.optional(), TEST_USER_PASSWORD: Env.schema.string.optional(), + + // Mail + MAIL_MAILER: Env.schema.enum(['smtp'] as const), + MAIL_FROM_NAME: Env.schema.string(), + MAIL_FROM_ADDRESS: Env.schema.string(), + SMTP_HOST: Env.schema.string({ format: 'host' }), + SMTP_PORT: Env.schema.number(), }) diff --git a/start/kernel.ts b/start/kernel.ts index 05c358a..5ec73e6 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -56,6 +56,7 @@ router.use([ () => import('@adonisjs/shield/shield_middleware'), () => import('@adonisjs/auth/initialize_auth_middleware'), () => import('#middleware/silent_auth_middleware'), + () => import('#middleware/initialize_bouncer_middleware'), ]) /** diff --git a/start/limiter.ts b/start/limiter.ts new file mode 100644 index 0000000..23fecc2 --- /dev/null +++ b/start/limiter.ts @@ -0,0 +1,22 @@ +/* +|-------------------------------------------------------------------------- +| Define HTTP limiters +|-------------------------------------------------------------------------- +| +| The "limiter.define" method creates an HTTP middleware to apply rate +| limits on a route or a group of routes. Feel free to define as many +| throttle middleware as needed. +| +*/ + +import env from '#start/env' +import limiter from '@adonisjs/limiter/services/main' + +/** + * Applied to register, login and forgot-password in start/routes.ts. + * Limits are read from THROTTLE_AUTH_MAX and THROTTLE_AUTH_WINDOW so an + * environment can tighten or relax them without a code change. + */ +export const authThrottle = limiter.define('auth', () => { + return limiter.allowRequests(env.get('THROTTLE_AUTH_MAX')).every(env.get('THROTTLE_AUTH_WINDOW')) +}) diff --git a/start/routes.ts b/start/routes.ts index 5615cc8..c7da70d 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -13,9 +13,16 @@ */ import router from '@adonisjs/core/services/router' +import { middleware } from '#start/kernel' +import { authThrottle } from '#start/limiter' +import { respond } from '#helpers/api_response' // Lazy import — AdonisJS resolves the module on the first matching request const HealthController = () => import('#controllers/health_controller') +const AuthController = () => import('#controllers/auth_controller') +const UsersController = () => import('#controllers/users_controller') +const RolesController = () => import('#controllers/roles_controller') +const PermissionsController = () => import('#controllers/permissions_controller') /* |-------------------------------------------------------------------------- @@ -37,3 +44,103 @@ router.get('/api/v1/health', [HealthController, 'index']) | their respective feature branches, starting with feature/auth. | */ + +/* +|-------------------------------------------------------------------------- +| Auth +|-------------------------------------------------------------------------- +| +| register, login and forgot-password are rate limited with authThrottle +| (THROTTLE_AUTH_MAX per THROTTLE_AUTH_WINDOW seconds, per the README) since +| they are the endpoints an attacker would script against — credential +| stuffing on login, mass account creation on register, and reset-token +| flooding on forgot-password. activate and reset-password are excluded: +| both already require an unguessable token as the actual defense, and +| logout/refresh/me are excluded because middleware.auth() already limits +| them to requests bearing a valid, non-blacklisted JWT. +| +*/ +router + .group(() => { + router.post('register', [AuthController, 'register']).use(authThrottle) + router.post('activate', [AuthController, 'activate']) + router.post('login', [AuthController, 'login']).use(authThrottle) + router.post('logout', [AuthController, 'logout']).use(middleware.auth()) + router.post('refresh', [AuthController, 'refresh']).use(middleware.auth()) + router.post('forgot-password', [AuthController, 'forgotPassword']).use(authThrottle) + router.post('reset-password', [AuthController, 'resetPassword']) + router.get('me', [AuthController, 'me']).use(middleware.auth()) + }) + .prefix('/api/v1/auth') + +/* +|-------------------------------------------------------------------------- +| User, role and permission administration +|-------------------------------------------------------------------------- +| +| Registration always assigns the default USER role and nothing else in +| the app manages `users`, `roles` or `permissions` afterward — these +| endpoints are the only way to promote an account to ADMIN, create a new +| role, or change what a role can do. Every single route below operates on +| other accounts or on the authorization model itself, so all of them are +| ADMIN only, applied at route declaration time via the group-level +| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) rather +| than added retroactively per-route. +| +| Note there is deliberately no GET /api/v1/permissions/:id route: the +| README's Endpoints table for this subsection lists a detail route for +| users and roles but not for permissions. +| +*/ +router + .group(() => { + router.get('users', [UsersController, 'index']) + router.get('users/:id', [UsersController, 'show']) + router.put('users/:id', [UsersController, 'update']) + router.delete('users/:id', [UsersController, 'destroy']) + router.post('users/:id/roles', [UsersController, 'assignRole']) + router.delete('users/:id/roles/:roleId', [UsersController, 'revokeRole']) + + router.get('roles', [RolesController, 'index']) + router.get('roles/:id', [RolesController, 'show']) + router.post('roles', [RolesController, 'store']) + router.put('roles/:id', [RolesController, 'update']) + router.delete('roles/:id', [RolesController, 'destroy']) + router.post('roles/:id/permissions', [RolesController, 'assignPermission']) + router.delete('roles/:id/permissions/:permissionId', [RolesController, 'revokePermission']) + + router.get('permissions', [PermissionsController, 'index']) + router.post('permissions', [PermissionsController, 'store']) + router.put('permissions/:id', [PermissionsController, 'update']) + router.delete('permissions/:id', [PermissionsController, 'destroy']) + }) + .prefix('/api/v1') + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) + +/* +|-------------------------------------------------------------------------- +| Temporary middleware smoke-test stubs (feature/auth only) +|-------------------------------------------------------------------------- +| +| feature/auth's task list requires a functional test proving AuthMiddleware +| and RoleMiddleware actually reject/allow requests (401 with no token, 200 +| with a valid USER token, 403 for USER / 200 for ADMIN behind role('ADMIN')). +| No real protected resource route exists yet - categories, products, +| customers and orders are all later branches - so these two routes exist +| solely to give that test something to hit. +| +| DELETE both routes once feature/categories adds its first ADMIN-protected +| route; they carry no business meaning of their own. +| +*/ +router + .get('/api/v1/_stub/protected', ({ response }) => { + return response.ok(respond({ ok: true }, 'Protected stub reached')) + }) + .use(middleware.auth()) + +router + .get('/api/v1/_stub/protected-admin', ({ response }) => { + return response.ok(respond({ ok: true }, 'Protected admin stub reached')) + }) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) diff --git a/tests/functional/admin_auth_matrix.spec.ts b/tests/functional/admin_auth_matrix.spec.ts new file mode 100644 index 0000000..e2d84f4 --- /dev/null +++ b/tests/functional/admin_auth_matrix.spec.ts @@ -0,0 +1,84 @@ +/** + * Functional tests proving every user/role/permission administration route + * is actually gated by both `middleware.auth()` and + * `middleware.role({ roles: ['ADMIN'] })`, not just the routes someone + * happened to write a CRUD test against. Each of the seventeen admin + * routes is exercised twice: once with no token at all, which must be + * rejected by AuthMiddleware before RoleMiddleware or the controller ever + * runs, and once with a valid but non-ADMIN (seeded USER) token, which must + * pass authentication but be rejected by RoleMiddleware. + * + * Route params in the URLs below (`1`) are placeholders rather than real + * ids: both checks happen in middleware, before any controller action + * touches the database, so a route returning 401 or 403 never depends on + * whether a user, role or permission with that id actually exists. Missing + * this coverage on even one route would leave a silent hole in the + * authorization model this whole administration surface exists to protect. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import type { ApiClient } from '@japa/api-client' + +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +type AdminRoute = { method: 'get' | 'post' | 'put' | 'delete'; url: string } + +const adminRoutes: AdminRoute[] = [ + { method: 'get', url: '/api/v1/users' }, + { method: 'get', url: '/api/v1/users/1' }, + { method: 'put', url: '/api/v1/users/1' }, + { method: 'delete', url: '/api/v1/users/1' }, + { method: 'post', url: '/api/v1/users/1/roles' }, + { method: 'delete', url: '/api/v1/users/1/roles/1' }, + { method: 'get', url: '/api/v1/roles' }, + { method: 'get', url: '/api/v1/roles/1' }, + { method: 'post', url: '/api/v1/roles' }, + { method: 'put', url: '/api/v1/roles/1' }, + { method: 'delete', url: '/api/v1/roles/1' }, + { method: 'post', url: '/api/v1/roles/1/permissions' }, + { method: 'delete', url: '/api/v1/roles/1/permissions/1' }, + { method: 'get', url: '/api/v1/permissions' }, + { method: 'post', url: '/api/v1/permissions' }, + { method: 'put', url: '/api/v1/permissions/1' }, + { method: 'delete', url: '/api/v1/permissions/1' }, +] + +function requestFor(client: ApiClient, route: AdminRoute, token?: string) { + const base = + route.method === 'get' + ? client.get(route.url) + : route.method === 'delete' + ? client.delete(route.url) + : route.method === 'post' + ? client.post(route.url).json({}) + : client.put(route.url).json({}) + + return token ? base.bearerToken(token) : base +} + +test.group('Admin routes - authorization matrix', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('every admin route returns 401 without a token', async ({ client }) => { + for (const route of adminRoutes) { + const response = await requestFor(client, route) + response.assertStatus(401) + } + }).timeout(30000) + + test('every admin route returns 403 with a non-ADMIN (USER) token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + for (const route of adminRoutes) { + const response = await requestFor(client, route, token) + response.assertStatus(403) + } + }).timeout(30000) +}) diff --git a/tests/functional/auth.spec.ts b/tests/functional/auth.spec.ts new file mode 100644 index 0000000..d65c7b0 --- /dev/null +++ b/tests/functional/auth.spec.ts @@ -0,0 +1,169 @@ +/** + * Functional tests for the auth HTTP surface: the full account lifecycle + * through real requests, the AuthMiddleware / RoleMiddleware smoke test + * against the temporary stub routes, and rate limiting on the login route. + * + * Before this branch there was no coverage proving the pieces documented in + * app/services/auth_service.ts and app/middleware/*.ts actually compose + * correctly behind real HTTP requests: that register's activation token + * really unlocks activate, that a logged-out token is really rejected by + * the next request rather than only by a direct unit call, and that the + * role middleware really distinguishes ADMIN from USER using the seeded + * accounts rather than some hand-rolled fixture. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import env from '#start/env' +import ActivationToken from '#models/activation_token' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Auth - full lifecycle (register -> activate -> login -> me -> logout)', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // register() requires the USER role to exist; make this group + // self-sufficient regardless of what ran earlier in the process. + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('register, activate, login, fetch profile, logout, then reject the revoked token', async ({ + client, + assert, + }) => { + const email = `lifecycle-${crypto.randomUUID()}@example.com` + const password = 'Password1!' + + // 1. register + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Full', + lastName: 'Flow', + email, + password, + }) + registerResponse.assertStatus(201) + registerResponse.assertBodyContains({ success: true }) + const userId = registerResponse.body().data.id as number + assert.isNumber(userId) + + // The activation token is no longer echoed in the response: it is + // emailed via ActivationMail against Mailhog now that a real mailer is + // wired up on this branch. The row is still persisted exactly as + // before, so it is looked up directly by the new user's id, most + // recent first, the same way any other out-of-band consumer of the + // token would have to. + const tokenRow = await ActivationToken.query() + .where('userId', userId) + .orderBy('createdAt', 'desc') + .firstOrFail() + const activationToken = tokenRow.token + assert.isString(activationToken) + + // 2. activate + const activateResponse = await client + .post('/api/v1/auth/activate') + .json({ token: activationToken }) + activateResponse.assertStatus(200) + assert.isTrue(activateResponse.body().data.enabled) + + // 3. login + const loginResponse = await client.post('/api/v1/auth/login').json({ email, password }) + loginResponse.assertStatus(200) + const token = loginResponse.body().data.token as string + assert.isString(token) + + // 4. GET /auth/me + const meResponse = await client.get('/api/v1/auth/me').bearerToken(token) + meResponse.assertStatus(200) + assert.equal(meResponse.body().data.email, email) + + // 5. logout + const logoutResponse = await client.post('/api/v1/auth/logout').bearerToken(token) + logoutResponse.assertStatus(200) + + // 6. GET /auth/me with the now-revoked token + const revokedMeResponse = await client.get('/api/v1/auth/me').bearerToken(token) + revokedMeResponse.assertStatus(401) + }).timeout(15000) +}) + +test.group('Auth - middleware smoke test (AuthMiddleware + RoleMiddleware)', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // Logging in as the seeded ADMIN/USER accounts requires those rows to + // exist; ensure them here so this group does not depend on seed data + // planted by an unrelated file earlier in the run. + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('protected stub route returns 401 without a token', async ({ client }) => { + const response = await client.get('/api/v1/_stub/protected') + response.assertStatus(401) + }) + + test('protected stub route returns 200 with a valid USER token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/_stub/protected').bearerToken(token) + response.assertStatus(200) + }).timeout(10000) + + test('admin-protected stub route returns 403 for a USER token', async ({ client }) => { + const token = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) + response.assertStatus(403) + }).timeout(10000) + + test('admin-protected stub route returns 200 for an ADMIN token', async ({ client }) => { + const token = await loginAsSeeded(client, 'admin') + + const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) + response.assertStatus(200) + }).timeout(10000) +}) + +test.group('Auth - rate limiting on login', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('the request past THROTTLE_AUTH_MAX within the window returns 429', async ({ + client, + assert, + }) => { + // register, login and forgot-password all share a single named limiter + // ("auth"), keyed by client IP, so any of them running earlier in this + // process (the full lifecycle test above included) consumes from the + // same bucket. Clearing rows for that limiter's key prefix immediately + // before the burst guarantees a clean window regardless of what ran + // before this test or in what order, instead of relying on nothing else + // in the suite having touched the auth limiter yet. + await db.rawQuery("delete from rate_limits where key like 'auth_%'") + + const email = env.get('TEST_USER_EMAIL')! + const password = env.get('TEST_USER_PASSWORD')! + const maxRequests = env.get('THROTTLE_AUTH_MAX') + + for (let attempt = 0; attempt < maxRequests; attempt++) { + const response = await client.post('/api/v1/auth/login').json({ email, password }) + response.assertStatus(200) + } + + const blockedResponse = await client.post('/api/v1/auth/login').json({ email, password }) + blockedResponse.assertStatus(429) + assert.equal(blockedResponse.body().success, false) + }).timeout(20000) +}) diff --git a/tests/functional/permissions.spec.ts b/tests/functional/permissions.spec.ts new file mode 100644 index 0000000..774856f --- /dev/null +++ b/tests/functional/permissions.spec.ts @@ -0,0 +1,142 @@ +/** + * Functional tests for the permission administration HTTP surface: create, + * list, update and delete a permission, plus the 409 business rule that a + * permission still attached to at least one role cannot be deleted. + * + * Mirrors roles.spec.ts's reasoning: the unit spec for PermissionsService + * already proves the guard throws the right error at the service layer, + * but nothing proved the HTTP layer actually turns it into a 409 + * ApiResponse, or that the create -> list -> update -> delete cycle works + * end to end. There is deliberately no detail-route assertion here (no + * `GET /api/v1/permissions/:id` exists per the README), so removal is + * confirmed by re-listing instead. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +/** + * `response.body()` is typed against the union of every action registered + * on a matching literal route (for example both PermissionsController.index + * and .store resolve to /api/v1/permissions), so TypeScript cannot narrow + * `.data` to the specific shape a given call actually returns. This helper + * casts to `any` at the single point every access in this file goes + * through, rather than repeating the same cast at every property read. + */ +function body(response: { body(): unknown }): any { + return response.body() +} + +test.group('Permissions admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, update, then delete a permission, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // POST /api/v1/permissions + const resource = `resource-${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/permissions') + .json({ resource, action: 'read' }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const permissionId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.resource, resource) + assert.equal(body(createResponse).data.action, 'read') + + // GET /api/v1/permissions + const listResponse = await client + .get('/api/v1/permissions') + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(body(listResponse).data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.isTrue(body(listResponse).data.items.some((p: { id: number }) => p.id === permissionId)) + + // PUT /api/v1/permissions/:id + const updateResponse = await client + .put(`/api/v1/permissions/${permissionId}`) + .json({ action: 'write' }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(body(updateResponse).data.action, 'write') + + // DELETE /api/v1/permissions/:id + const deleteResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + + const afterDeleteListResponse = await client + .get('/api/v1/permissions') + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + afterDeleteListResponse.assertStatus(200) + assert.isFalse( + body(afterDeleteListResponse).data.items.some((p: { id: number }) => p.id === permissionId) + ) + }).timeout(20000) + + test('deleting a permission still attached to a role returns 409, and succeeds once revoked', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const resource = `resource-${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/permissions') + .json({ resource, action: 'delete' }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + const permissionId = body(createResponse).data.id as number + + const roleName = `ROLE_${crypto.randomUUID()}` + const createRoleResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createRoleResponse.assertStatus(201) + const roleId = body(createRoleResponse).data.id as number + + await client + .post(`/api/v1/roles/${roleId}/permissions`) + .json({ permissionId }) + .bearerToken(adminToken) + + const blockedResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(body(blockedResponse).success) + + await client + .delete(`/api/v1/roles/${roleId}/permissions/${permissionId}`) + .bearerToken(adminToken) + + const deleteResponse = await client + .delete(`/api/v1/permissions/${permissionId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) diff --git a/tests/functional/roles.spec.ts b/tests/functional/roles.spec.ts new file mode 100644 index 0000000..6872ef0 --- /dev/null +++ b/tests/functional/roles.spec.ts @@ -0,0 +1,163 @@ +/** + * Functional tests for the role administration HTTP surface: the full CRUD + * lifecycle (create, list, detail, update, assign/revoke a permission, + * delete) plus the 409 business rule that a role still assigned to at + * least one user cannot be deleted. + * + * The unit spec for RolesService already proves the guard throws the right + * error at the service layer; what was still unproven is that the HTTP + * layer actually surfaces it as a 409 with the ApiResponse envelope rather + * than, say, letting it fall through to a generic 500, and that the whole + * create -> read -> update -> assign -> revoke -> delete cycle a real ADMIN + * client would perform actually works end to end over real requests rather + * than only through direct service calls. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Permission from '#models/permission' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +/** + * `response.body()` on a literal-path match (POST and GET both resolve to + * /api/v1/roles) is typed against the union of every action on that route, + * so TypeScript cannot narrow `.data` to the specific shape a given call + * actually returns. This cast is only needed at the two create-role call + * sites below; every other `.body()` access in this file hits a route with + * a dynamic `:id` segment, which Tuyau does not resolve to a literal type. + */ +function body(response: { body(): unknown }): any { + return response.body() +} + +test.group('Roles admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + // The second test registers a fresh account through the public auth + // flow to have a real user to assign the role under test to. + // AuthService.register looks up the USER role by name, so it must + // exist in this transaction the same way users.spec.ts already + // ensures it, ensureSeededUser('admin') alone only guarantees ADMIN. + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, read, update, assign/revoke a permission, then delete a role, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // POST /api/v1/roles + const roleName = `ROLE_${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const roleId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.roleName, roleName) + + // GET /api/v1/roles + const listResponse = await client + .get('/api/v1/roles') + .qs({ page: 1, limit: 10 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(listResponse.body().data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + + // GET /api/v1/roles/:id + const showResponse = await client.get(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.equal(showResponse.body().data.id, roleId) + assert.isArray(showResponse.body().data.permissions) + + // PUT /api/v1/roles/:id + const newRoleName = `${roleName}_UPDATED` + const updateResponse = await client + .put(`/api/v1/roles/${roleId}`) + .json({ roleName: newRoleName }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(updateResponse.body().data.roleName, newRoleName) + + // POST /api/v1/roles/:id/permissions + const permission = await Permission.create({ + resource: `resource-${crypto.randomUUID()}`, + action: 'read', + }) + const assignResponse = await client + .post(`/api/v1/roles/${roleId}/permissions`) + .json({ permissionId: permission.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + assert.isTrue( + assignResponse.body().data.permissions.some((p: { id: number }) => p.id === permission.id) + ) + + // DELETE /api/v1/roles/:id/permissions/:permissionId + const revokeResponse = await client + .delete(`/api/v1/roles/${roleId}/permissions/${permission.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + assert.isFalse( + revokeResponse.body().data.permissions.some((p: { id: number }) => p.id === permission.id) + ) + + // DELETE /api/v1/roles/:id + const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + + const afterDeleteResponse = await client.get(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + }).timeout(20000) + + test('deleting a role still assigned to a user returns 409, and succeeds once revoked', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const roleName = `ROLE_${crypto.randomUUID()}` + const createResponse = await client + .post('/api/v1/roles') + .json({ roleName }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + const roleId = body(createResponse).data.id as number + + const email = `rolebusy-${crypto.randomUUID()}@example.com` + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Role', + lastName: 'Busy', + email, + password: 'Password1!', + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + await client.post(`/api/v1/users/${userId}/roles`).json({ roleId }).bearerToken(adminToken) + + const blockedResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(blockedResponse.body().success) + + await client.delete(`/api/v1/users/${userId}/roles/${roleId}`).bearerToken(adminToken) + + const deleteResponse = await client.delete(`/api/v1/roles/${roleId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) diff --git a/tests/functional/users.spec.ts b/tests/functional/users.spec.ts new file mode 100644 index 0000000..420c2e7 --- /dev/null +++ b/tests/functional/users.spec.ts @@ -0,0 +1,200 @@ +/** + * Functional tests for the user administration HTTP surface: the full CRUD + * lifecycle (list, detail, update, delete) and, most importantly, the + * behavioural proof that assigning or revoking a role through + * `POST /users/:id/roles` and `DELETE /users/:id/roles/:roleId` actually + * changes what that user's own JWT is authorized to do on a subsequent + * request, not just what the `users` table says. + * + * Before this branch there was no coverage proving RoleMiddleware really + * re-checks roles fresh on every request rather than caching whatever was + * true when the JWT was issued. Getting that wrong in either direction + * would be dangerous: a stale cache that keeps trusting a revoked ADMIN + * role would let a demoted account keep acting as an admin until their + * token expires, and one that never picks up a newly granted role would + * force every promoted user to log out and back in before the promotion + * takes effect, silently breaking the endpoint this branch exists to add. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Role from '#models/role' +import ActivationToken from '#models/activation_token' +import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' + +test.group('Users admin - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('list, read, update, assign/revoke a role, then delete a user, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // seed the account this test exercises + const targetEmail = `admin-crud-${crypto.randomUUID()}@example.com` + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Target', + lastName: 'User', + email: targetEmail, + password: 'Password1!', + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + // GET /api/v1/users - paginated list + const listResponse = await client + .get('/api/v1/users') + .qs({ page: 1, limit: 10 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(listResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isTrue(listResponse.body().success) + assert.properties(listResponse.body().data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.isArray(listResponse.body().data.items) + + // GET /api/v1/users/:id - detail with roles preloaded + const showResponse = await client.get(`/api/v1/users/${userId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.properties(showResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.equal(showResponse.body().data.id, userId) + assert.isArray(showResponse.body().data.roles) + assert.isTrue( + showResponse.body().data.roles.some((r: { roleName: string }) => r.roleName === 'USER') + ) + + // PUT /api/v1/users/:id - partial update + const updateResponse = await client + .put(`/api/v1/users/${userId}`) + .json({ firstName: 'Updated', accountLocked: true }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.properties(updateResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.equal(updateResponse.body().data.firstName, 'Updated') + assert.isTrue(updateResponse.body().data.accountLocked) + + // POST /api/v1/users/:id/roles - assign a second role + const extraRole = await Role.firstOrCreate( + { roleName: `EXTRA_${crypto.randomUUID()}` }, + { roleName: `EXTRA_${crypto.randomUUID()}` } + ) + const assignResponse = await client + .post(`/api/v1/users/${userId}/roles`) + .json({ roleId: extraRole.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + assert.properties(assignResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isTrue( + assignResponse.body().data.roles.some((r: { id: number }) => r.id === extraRole.id) + ) + + // DELETE /api/v1/users/:id/roles/:roleId - revoke it back off + const revokeResponse = await client + .delete(`/api/v1/users/${userId}/roles/${extraRole.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + assert.properties(revokeResponse.body(), ['success', 'message', 'data', 'timestamp']) + assert.isFalse( + revokeResponse.body().data.roles.some((r: { id: number }) => r.id === extraRole.id) + ) + + // DELETE /api/v1/users/:id + const deleteResponse = await client.delete(`/api/v1/users/${userId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.properties(deleteResponse.body(), ['success', 'message', 'data', 'timestamp']) + + const afterDeleteResponse = await client.get(`/api/v1/users/${userId}`).bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + }).timeout(20000) +}) + +test.group('Users admin - assigning/revoking a role changes JWT authorization', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureRole('USER') + await ensureRole('ADMIN') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('promoting then demoting a user changes what their own JWT can do, without re-login', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const adminRole = await ensureRole('ADMIN') + + // Register and activate a fresh, ordinary USER account, then log in as + // them to get the JWT under test. This token is never re-issued for the + // rest of the test: RoleMiddleware re-checks roles fresh on every + // request via user.load('roles'), so the same token must flip between + // 403 and 200 purely because of what the admin API does to the user's + // roles in between. + const email = `promote-${crypto.randomUUID()}@example.com` + const password = 'Password1!' + const registerResponse = await client.post('/api/v1/auth/register').json({ + firstName: 'Promote', + lastName: 'Me', + email, + password, + }) + registerResponse.assertStatus(201) + const userId = registerResponse.body().data.id as number + + const tokenRow = await ActivationToken.query() + .where('userId', userId) + .orderBy('createdAt', 'desc') + .firstOrFail() + await client.post('/api/v1/auth/activate').json({ token: tokenRow.token }) + + const loginResponse = await client.post('/api/v1/auth/login').json({ email, password }) + loginResponse.assertStatus(200) + const userToken = loginResponse.body().data.token as string + + // Not yet an ADMIN: an ADMIN-only route must reject this user's own JWT + const beforeResponse = await client.get('/api/v1/users').bearerToken(userToken) + beforeResponse.assertStatus(403) + + // Promote via the admin API, logged in as the seeded ADMIN + const assignResponse = await client + .post(`/api/v1/users/${userId}/roles`) + .json({ roleId: adminRole.id }) + .bearerToken(adminToken) + assignResponse.assertStatus(201) + + // Same original JWT, never re-issued, now succeeds + const afterAssignResponse = await client.get('/api/v1/users').bearerToken(userToken) + afterAssignResponse.assertStatus(200) + + // Revoke the ADMIN role + const revokeResponse = await client + .delete(`/api/v1/users/${userId}/roles/${adminRole.id}`) + .bearerToken(adminToken) + revokeResponse.assertStatus(200) + + // Same original JWT, back to 403 + const afterRevokeResponse = await client.get('/api/v1/users').bearerToken(userToken) + afterRevokeResponse.assertStatus(403) + + assert.isNumber(userId) + }).timeout(20000) +}) diff --git a/tests/helpers/auth_helper.ts b/tests/helpers/auth_helper.ts new file mode 100644 index 0000000..415bb21 --- /dev/null +++ b/tests/helpers/auth_helper.ts @@ -0,0 +1,91 @@ +/** + * Shared login helper for functional tests that need a JWT for one of the + * seeded accounts (feature/data-modeling's UserSeeder). Credentials are read + * from the typed env service, never hardcoded, so this helper stays correct + * if the seeded passwords ever change in .env.test. + * + * The token is obtained through the real POST /api/v1/auth/login endpoint + * rather than by signing a JWT directly, so callers exercise the same + * credential-check and JWT-issuance path a real client would go through. + * + * This file also exports `ensureRole` and `ensureSeededUser`. They exist + * because tests/unit/database/migrations.spec.ts (from feature/data-modeling, + * not touched by this branch) rolls every migration down and back up as part + * of its own coverage, which drops all seed data as a real side effect since + * that test intentionally does not wrap the migrator in a rollback-able + * transaction. When the unit and functional suites run in the same process + * (the default for `node ace test`), any auth test that runs after that + * migration test would otherwise find the roles table and the seeded + * ADMIN/USER accounts empty. These helpers make each test group + * self-sufficient by ensuring the reference data it needs exists, inside + * that group's own rolled-back global transaction, instead of depending on + * seed data planted by an unrelated file earlier in the run. + */ +import type { ApiClient } from '@japa/api-client' +import env from '#start/env' +import Role from '#models/role' +import User from '#models/user' + +export type SeededRole = 'admin' | 'user' + +function credentialsFor(role: SeededRole): { email: string; password: string } { + if (role === 'admin') { + return { + email: env.get('TEST_ADMIN_EMAIL')!, + password: env.get('TEST_ADMIN_PASSWORD')!, + } + } + + return { + email: env.get('TEST_USER_EMAIL')!, + password: env.get('TEST_USER_PASSWORD')!, + } +} + +/** + * Ensures a role with the given name exists, creating it when it is + * missing. Idempotent: safe to call from every test group that depends on + * the role being present. + */ +export async function ensureRole(roleName: string): Promise { + return Role.firstOrCreate({ roleName }, { roleName }) +} + +/** + * Ensures the seeded ADMIN or USER account (matching the TEST_*_EMAIL / + * TEST_*_PASSWORD env vars) exists, is enabled, and has its role attached. + */ +export async function ensureSeededUser(role: SeededRole): Promise { + const { email, password } = credentialsFor(role) + const roleName = role === 'admin' ? 'ADMIN' : 'USER' + const roleRow = await ensureRole(roleName) + + let user = await User.findBy('email', email) + if (!user) { + user = await User.create({ + firstName: 'Seeded', + lastName: role === 'admin' ? 'Admin' : 'User', + email, + password, + enabled: true, + accountLocked: false, + }) + } + + await user.related('roles').sync([roleRow.id], false) + + return user +} + +/** + * Logs in as the seeded ADMIN or USER account through the real login + * endpoint and returns the issued JWT string. + */ +export async function loginAsSeeded(client: ApiClient, role: SeededRole): Promise { + const { email, password } = credentialsFor(role) + + const response = await client.post('/api/v1/auth/login').json({ email, password }) + response.assertStatus(200) + + return response.body().data.token as string +} diff --git a/tests/unit/middleware/auth_middleware.spec.ts b/tests/unit/middleware/auth_middleware.spec.ts new file mode 100644 index 0000000..508d45c --- /dev/null +++ b/tests/unit/middleware/auth_middleware.spec.ts @@ -0,0 +1,138 @@ +/** + * Unit test for AuthMiddleware's blacklist check in isolation from the rest + * of the HTTP stack. AuthMiddleware's whole purpose beyond authenticating + * the request is rejecting a JWT whose jti has already been logged out; that + * behaviour is also exercised end to end by the functional "revoked token + * returns 401" scenario in tests/functional/auth.spec.ts, but this unit test + * pins down the middleware's own branching logic directly, without needing + * a real HTTP round trip or a signed JWT, so a regression in the query + * against blacklisted_tokens fails here specifically rather than only + * surfacing as a vague functional test failure. + * + * The middleware is exercised against a minimal fake HttpContext exposing + * just the `auth` and `response` shape it actually reads, since constructing + * a real HttpContext outside of an HTTP request is not practical. The + * blacklist row itself is real, written to the test database inside a + * rolled-back global transaction, since AuthMiddleware queries + * BlacklistedToken directly rather than through anything mockable at the + * model layer. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import type { HttpContext } from '@adonisjs/core/http' + +import AuthMiddleware from '#middleware/auth_middleware' +import User from '#models/user' +import BlacklistedToken from '#models/blacklisted_token' + +function fakeCtx(jti: string, user: User) { + const responses: { unauthorized?: unknown } = {} + + const ctx = { + auth: { + authenticateUsing: async () => user, + use: () => ({ payload: { jti } }), + }, + request: { + url: () => '/api/v1/_stub/protected', + }, + response: { + unauthorized: (body: unknown) => { + responses.unauthorized = body + return body + }, + }, + } as unknown as HttpContext + + return { ctx, responses } +} + +test.group('AuthMiddleware - blacklisted jti rejection', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('rejects a request whose jti is in blacklisted_tokens', async ({ assert }) => { + const user = await User.create({ + firstName: 'Blacklisted', + lastName: 'Token', + email: `blacklist-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + + const jti = crypto.randomUUID() + await BlacklistedToken.create({ + userId: user.id, + token: 'revoked-raw-token', + jti, + blacklistedAt: DateTime.now(), + expiresAt: DateTime.now().plus({ hours: 1 }), + }) + + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(jti, user) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isFalse(nextCalled) + assert.isDefined(responses.unauthorized) + assert.deepInclude(responses.unauthorized, { success: false }) + }).timeout(10000) + + test('calls next() when the jti is not blacklisted', async ({ assert }) => { + const user = await User.create({ + firstName: 'Active', + lastName: 'User', + email: `active-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(crypto.randomUUID(), user) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isTrue(nextCalled) + assert.isUndefined(responses.unauthorized) + }).timeout(10000) + + test('rejects a request for a user who is disabled or locked, even with a valid non-blacklisted jti', async ({ + assert, + }) => { + const user = await User.create({ + firstName: 'Locked', + lastName: 'Out', + email: `locked-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: true, + }) + + const middleware = new AuthMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(crypto.randomUUID(), user) + + await middleware.handle(ctx, async () => { + nextCalled = true + }) + + assert.isFalse(nextCalled) + assert.isDefined(responses.unauthorized) + assert.deepInclude(responses.unauthorized, { success: false }) + }).timeout(10000) +}) diff --git a/tests/unit/services/auth_service.spec.ts b/tests/unit/services/auth_service.spec.ts new file mode 100644 index 0000000..98239cd --- /dev/null +++ b/tests/unit/services/auth_service.spec.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for AuthService, the class that owns the entire account + * lifecycle (register, activate, login, logout). Before this branch, there + * was no coverage at all proving that register actually attaches the + * default USER role and issues a usable activation token, that activate + * actually flips enabled and marks the token consumed, or that login + * enforces its three failure modes in a way callers (and the middleware + * layer built on top of it) can depend on. Getting any one of these wrong + * would not fail loudly: a missing role attach would silently leave a new + * account with no permissions, and a login check performed in the wrong + * order would leak which accounts are activated versus locked to an + * attacker probing the endpoint. + * + * These tests hit the real test database inside a rolled-back global + * transaction, following the same pattern as the model specs from + * feature/data-modeling, rather than mocking Lucid models. AuthService's + * behaviour only means anything once persisted rows (the role pivot, the + * activation token, the blacklist entry) are read back, so a mock-based + * test would just restate the implementation instead of verifying it. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import { DateTime } from 'luxon' +import { decodeJwt } from 'jose' + +import AuthService from '#services/auth_service' +import User from '#models/user' +import ActivationToken from '#models/activation_token' +import BlacklistedToken from '#models/blacklisted_token' +import type { JwtPayload } from '#auth/jwt_guard' +import { ensureRole } from '#tests/helpers/auth_helper' + +const authService = new AuthService() + +async function createUser( + overrides: Partial<{ + email: string + password: string + enabled: boolean + accountLocked: boolean + }> = {} +) { + return User.create({ + firstName: 'Test', + lastName: 'Account', + email: overrides.email ?? `user-${crypto.randomUUID()}@example.com`, + password: overrides.password ?? 'Password1!', + enabled: overrides.enabled ?? true, + accountLocked: overrides.accountLocked ?? false, + }) +} + +test.group('AuthService - register / activate', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + // register() looks up the USER role by name; guaranteeing it exists + // here keeps this file independent of seed data planted elsewhere. + await ensureRole('USER') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('register creates the user with the default USER role and an activation token', async ({ + assert, + }) => { + const email = `register-${crypto.randomUUID()}@example.com` + + const user = await authService.register({ + firstName: 'Ada', + lastName: 'Lovelace', + email, + password: 'Password1!', + }) + + assert.isNumber(user.id) + assert.equal(user.email, email) + assert.isFalse(user.enabled) + + await user.load('roles') + assert.isTrue(user.roles.some((role) => role.roleName === 'USER')) + + // register() no longer returns the activation token directly (it is + // emailed via ActivationMail instead), so the persisted row is looked + // up by userId, most recent first, the same way a caller with only the + // user's id would have to. + const tokenRow = await ActivationToken.query() + .where('userId', user.id) + .orderBy('createdAt', 'desc') + .firstOrFail() + assert.isString(tokenRow.token) + assert.isNotEmpty(tokenRow.token) + assert.equal(tokenRow.userId, user.id) + assert.isNull(tokenRow.validatedAt) + }).timeout(10000) + + test('activate sets enabled to true and marks the activation token as used', async ({ + assert, + }) => { + const user = await authService.register({ + firstName: 'Grace', + lastName: 'Hopper', + email: `activate-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + }) + + const tokenRow = await ActivationToken.query() + .where('userId', user.id) + .orderBy('createdAt', 'desc') + .firstOrFail() + + const activatedUser = await authService.activate(tokenRow.token) + + assert.isTrue(activatedUser.enabled) + + const reloadedUser = await User.findOrFail(user.id) + assert.isTrue(reloadedUser.enabled) + + const reloadedToken = await ActivationToken.query().where('token', tokenRow.token).firstOrFail() + assert.isNotNull(reloadedToken.validatedAt) + }).timeout(10000) +}) + +test.group('AuthService - login', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_INVALID_CREDENTIALS for a wrong password', async ({ assert }) => { + const email = `wrongpass-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: true, accountLocked: false }) + + try { + await authService.login({ email, password: 'WrongPass1!' }) + assert.fail('expected login to throw for a wrong password') + } catch (error: any) { + assert.equal(error.code, 'E_INVALID_CREDENTIALS') + assert.equal(error.status, 400) + } + }).timeout(10000) + + test('throws E_ACCOUNT_LOCKED for a locked account', async ({ assert }) => { + const email = `locked-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: true, accountLocked: true }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw for a locked account') + } catch (error: any) { + assert.equal(error.code, 'E_ACCOUNT_LOCKED') + assert.equal(error.status, 403) + } + }).timeout(10000) + + test('throws E_ACCOUNT_NOT_ACTIVATED for an inactive account', async ({ assert }) => { + const email = `inactive-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: false, accountLocked: false }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw for an inactive account') + } catch (error: any) { + assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') + assert.equal(error.status, 403) + } + }).timeout(10000) + + test('checks enabled before accountLocked when an account is both inactive and locked', async ({ + assert, + }) => { + const email = `both-${crypto.randomUUID()}@example.com` + await createUser({ email, password: 'CorrectPass1!', enabled: false, accountLocked: true }) + + try { + await authService.login({ email, password: 'CorrectPass1!' }) + assert.fail('expected login to throw') + } catch (error: any) { + assert.equal(error.code, 'E_ACCOUNT_NOT_ACTIVATED') + } + }).timeout(10000) + + test('returns a JWT whose payload contains a jti claim', async ({ assert }) => { + const email = `jti-${crypto.randomUUID()}@example.com` + const user = await createUser({ + email, + password: 'CorrectPass1!', + enabled: true, + accountLocked: false, + }) + + const result = await authService.login({ email, password: 'CorrectPass1!' }) + + assert.isString(result.token) + + const payload = decodeJwt(result.token) as JwtPayload + assert.isString(payload.jti) + assert.isNotEmpty(payload.jti) + assert.equal(payload.sub, String(user.id)) + }).timeout(10000) +}) + +test.group('AuthService - logout', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('inserts the jti into blacklisted_tokens', async ({ assert }) => { + const user = await createUser({ email: `logout-${crypto.randomUUID()}@example.com` }) + + const payload: JwtPayload = { + sub: String(user.id), + jti: crypto.randomUUID(), + iat: Math.floor(DateTime.now().toSeconds()), + exp: Math.floor(DateTime.now().plus({ hours: 1 }).toSeconds()), + } + const rawToken = 'raw-token-value-for-test' + + await authService.logout(user, payload, rawToken) + + const blacklisted = await BlacklistedToken.findBy('jti', payload.jti) + assert.isNotNull(blacklisted) + assert.equal(blacklisted!.userId, user.id) + assert.equal(blacklisted!.token, rawToken) + }).timeout(10000) +}) diff --git a/tests/unit/services/permissions_service.spec.ts b/tests/unit/services/permissions_service.spec.ts new file mode 100644 index 0000000..76ca497 --- /dev/null +++ b/tests/unit/services/permissions_service.spec.ts @@ -0,0 +1,67 @@ +/** + * Unit tests for PermissionsService.remove's business rule: a permission + * still attached to at least one role cannot be deleted, mirroring + * RolesService's rule for roles still assigned to a user. Without this + * guard, deleting a permission a role still grants would either violate + * the `role_permission` FK constraint as an unhandled 500 or, worse, + * silently leave that role pointing at a permission row that no longer + * exists. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as roles_service.spec.ts, since the rule + * only means anything once a real `role_permission` pivot row exists to be + * checked against. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import PermissionsService from '#services/permissions_service' +import Permission from '#models/permission' +import { ensureRole } from '#tests/helpers/auth_helper' + +const permissionsService = new PermissionsService() + +async function createPermission() { + return Permission.create({ + resource: `resource-${crypto.randomUUID()}`, + action: 'read', + }) +} + +test.group('PermissionsService - remove', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_PERMISSION_IN_USE (409) when a role still has the permission', async ({ + assert, + }) => { + const permission = await createPermission() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + await role.related('permissions').attach([permission.id]) + + try { + await permissionsService.remove(permission.id) + assert.fail('expected remove to throw while a role still has the permission') + } catch (error: any) { + assert.equal(error.code, 'E_PERMISSION_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Permission.find(permission.id) + assert.isNotNull(stillExists) + }).timeout(10000) + + test('deletes the permission once no role has it attached', async ({ assert }) => { + const permission = await createPermission() + + await permissionsService.remove(permission.id) + + const deleted = await Permission.find(permission.id) + assert.isNull(deleted) + }).timeout(10000) +}) diff --git a/tests/unit/services/roles_service.spec.ts b/tests/unit/services/roles_service.spec.ts new file mode 100644 index 0000000..5ed2804 --- /dev/null +++ b/tests/unit/services/roles_service.spec.ts @@ -0,0 +1,71 @@ +/** + * Unit tests for RolesService.remove's business rule: a role still assigned + * to at least one user cannot be deleted, and must fail with 409 rather + * than either silently succeeding (leaving that user's `role_user` row + * pointing at a deleted role) or letting the database's own FK constraint + * surface as an unhandled 500. Nothing in this branch proved the guard + * actually runs before the delete, or that a role really is removable once + * it is no longer assigned to anyone. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as auth_service.spec.ts, since the rule + * only means anything once a real pivot row exists to be checked against. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import RolesService from '#services/roles_service' +import Role from '#models/role' +import User from '#models/user' +import { ensureRole } from '#tests/helpers/auth_helper' + +const rolesService = new RolesService() + +async function createUser() { + return User.create({ + firstName: 'Test', + lastName: 'User', + email: `user-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) +} + +test.group('RolesService - remove', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('throws E_ROLE_IN_USE (409) when a user is still assigned to the role', async ({ + assert, + }) => { + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + const user = await createUser() + await user.related('roles').attach([role.id]) + + try { + await rolesService.remove(role.id) + assert.fail('expected remove to throw while a user still has the role') + } catch (error: any) { + assert.equal(error.code, 'E_ROLE_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Role.find(role.id) + assert.isNotNull(stillExists) + }).timeout(10000) + + test('deletes the role once no user has it assigned', async ({ assert }) => { + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + await rolesService.remove(role.id) + + const deleted = await Role.find(role.id) + assert.isNull(deleted) + }).timeout(10000) +}) diff --git a/tests/unit/services/users_service.spec.ts b/tests/unit/services/users_service.spec.ts new file mode 100644 index 0000000..297876d --- /dev/null +++ b/tests/unit/services/users_service.spec.ts @@ -0,0 +1,74 @@ +/** + * Unit tests for UsersService.assignRole's idempotency guarantee. + * + * The service's own docstring promises that attaching a role a user already + * has is a no-op rather than a duplicate pivot row or a thrown error, and + * the `role_user` pivot table backs that promise with a composite primary + * key on (user_id, role_id): a naive `attach()` called twice without the + * existence check RolesService performs would raise a unique-constraint + * violation on the second call. Nothing in this branch proved that the + * check actually runs before the attach, only that the migration and the + * relation declaration exist. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as auth_service.spec.ts, since the only way + * to prove "no duplicate pivot row" is to read the pivot table back after + * calling the service twice. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import UsersService from '#services/users_service' +import User from '#models/user' +import { ensureRole } from '#tests/helpers/auth_helper' + +const usersService = new UsersService() + +async function createUser() { + return User.create({ + firstName: 'Test', + lastName: 'User', + email: `user-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) +} + +test.group('UsersService - assignRole idempotency', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('attaches a role the user does not have yet', async ({ assert }) => { + const user = await createUser() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + const updated = await usersService.assignRole(user.id, role.id) + + assert.isTrue(updated.roles.some((r) => r.id === role.id)) + + const pivotRows = await db.from('role_user').where('user_id', user.id).where('role_id', role.id) + assert.lengthOf(pivotRows, 1) + }).timeout(10000) + + test('assigning the same role twice does not duplicate the pivot row', async ({ assert }) => { + const user = await createUser() + const role = await ensureRole(`ROLE_${crypto.randomUUID()}`) + + await usersService.assignRole(user.id, role.id) + const updated = await usersService.assignRole(user.id, role.id) + + assert.lengthOf( + updated.roles.filter((r) => r.id === role.id), + 1 + ) + + const pivotRows = await db.from('role_user').where('user_id', user.id).where('role_id', role.id) + assert.lengthOf(pivotRows, 1) + }).timeout(10000) +})