From ec17ad19d6599d117ede85925e14b0017ccb8cc1 Mon Sep 17 00:00:00 2001 From: Ravinou Date: Sat, 3 May 2025 22:54:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20new=20account=20setup=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- package-lock.json | 9 ++ pages/api/v1/account/setup.test.ts | 222 +++++++++++++++++++++++++++++ pages/api/v1/account/setup.ts | 71 +++++++++ types/domain/config.types.ts | 3 +- 5 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 pages/api/v1/account/setup.test.ts create mode 100644 pages/api/v1/account/setup.ts diff --git a/.gitignore b/.gitignore index 53f9ab5..5b56d62 100644 --- a/.gitignore +++ b/.gitignore @@ -114,4 +114,5 @@ config/users.json docker-compose.yml # Commit tests docker-compose -!tests/bats/docker-compose.yml \ No newline at end of file +!tests/bats/docker-compose.yml +certificates \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9fbba88..09d7b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8392,6 +8392,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/pages/api/v1/account/setup.test.ts b/pages/api/v1/account/setup.test.ts new file mode 100644 index 0000000..833cd6d --- /dev/null +++ b/pages/api/v1/account/setup.test.ts @@ -0,0 +1,222 @@ +import { createMocks } from 'node-mocks-http'; +import handler from './setup'; +import { AuthService, ConfigService } from '~/services'; +import { BorgWarehouseUser, Repository } from '~/types'; + +vi.mock('~/services'); + +const bwUserMock: BorgWarehouseUser = { + id: 0, + username: 'testuser', + password: 'hashedpassword', + email: 'test@test.fr', + roles: ['admin'], +}; + +const repoListMock: Repository[] = [ + { + id: 1, + alias: 'repo1', + repositoryName: 'Repository 1', + status: true, + lastSave: 0, + storageSize: 1000, + storageUsed: 500, + sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey1', + comment: 'First repository', + }, + { + id: 2, + alias: 'repo2', + repositoryName: 'Repository 2', + status: false, + lastSave: 0, + storageSize: 2000, + storageUsed: 1500, + sshPublicKey: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEArandomkey2', + comment: 'Second repository', + }, +]; + +describe('API /api/v1/account/setup', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.resetModules(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should return 404 if DISABLE_AUTO_SETUP is true', async () => { + process.env.DISABLE_AUTO_SETUP = 'true'; + + const { req, res } = createMocks({ method: 'GET' }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + delete process.env.DISABLE_AUTO_SETUP; + }); + + it('should return 405 if method is not GET or POST', async () => { + const { req, res } = createMocks({ method: 'PUT' }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(405); + }); + + it('should return setup: false if users exist on GET', async () => { + vi.mocked(ConfigService.getUsersList).mockResolvedValue([bwUserMock]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + + const { req, res } = createMocks({ method: 'GET' }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ setup: false }); + delete process.env.DISABLE_AUTO_SETUP; + }); + + it('should return forbidden if repos exist but no users on GET', async () => { + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue(repoListMock); + + const { req, res } = createMocks({ method: 'GET' }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(403); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + status: 403, + message: + 'The setup has already been completed, and some repositories are already configured. To reset, please delete the config/repo.json file or rebuild/restore the config/users.json file.', + }); + }); + + it('should return setup: true if no users or repos exist on GET', async () => { + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + + const { req, res } = createMocks({ method: 'GET' }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ setup: true }); + }); + + it('should return 422 if username is invalid', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { username: 'invalid username byebye', password: 'bigbigpassword' }, + }); + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(422); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + error: 'Only a-z characters are allowed (1 to 20 char.)', + }); + }); + + it('should return 422 if password is too short', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { username: 'validuser', password: 'short' }, + }); + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(422); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + error: 'Password must be at least 12 characters long', + }); + }); + + it('should return 422 if email is invalid', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { username: 'validuser', password: 'validpassword123', email: 'invalidemail' }, + }); + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + await handler(req, res); + expect(res._getStatusCode()).toBe(422); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ + error: 'Invalid email address', + }); + }); + + it('should return 404 if users or repos already exist on POST', async () => { + vi.mocked(ConfigService.getUsersList).mockResolvedValue([bwUserMock]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + + const { req, res } = createMocks({ + method: 'POST', + }); + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + }); + + it('should complete setup successfully on valid POST', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { + username: 'validuser', + password: 'validpassword123', + email: 'user@example.com', + }, + }); + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + vi.mocked(AuthService.hashPassword).mockResolvedValue('hashedpassword'); + + await handler(req, res); + + expect(ConfigService.updateUsersList).toHaveBeenCalledWith([ + { + id: 0, + username: 'validuser', + password: 'hashedpassword', + email: 'user@example.com', + roles: ['admin'], + }, + ]); + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ status: 200, message: 'Setup completed successfully' }); + }); + + it('should complete setup successfully without email on valid POST', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: { + username: 'validuser', + password: 'validpassword123', + }, + }); + vi.mocked(ConfigService.getUsersList).mockResolvedValue([]); + vi.mocked(ConfigService.getRepoList).mockResolvedValue([]); + vi.mocked(AuthService.hashPassword).mockResolvedValue('hashedpassword'); + + await handler(req, res); + + expect(ConfigService.updateUsersList).toHaveBeenCalledWith([ + { + id: 0, + username: 'validuser', + password: 'hashedpassword', + roles: ['admin'], + }, + ]); + expect(res._getStatusCode()).toBe(200); + const responseData = JSON.parse(res._getData()); + expect(responseData).toEqual({ status: 200, message: 'Setup completed successfully' }); + }); +}); diff --git a/pages/api/v1/account/setup.ts b/pages/api/v1/account/setup.ts new file mode 100644 index 0000000..01d12c5 --- /dev/null +++ b/pages/api/v1/account/setup.ts @@ -0,0 +1,71 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { AuthService, ConfigService } from '~/services'; +import { z } from 'zod'; +import ApiResponse from '~/helpers/functions/apiResponse'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (process.env.DISABLE_AUTO_SETUP === 'true') { + return ApiResponse.notFound(res); + } + if (req.method !== 'GET' && req.method !== 'POST') { + return ApiResponse.methodNotAllowed(res); + } + + const repoList = await ConfigService.getRepoList(); + const usersList = await ConfigService.getUsersList(); + + const hasUsers = usersList.length > 0; + const hasRepos = repoList.length > 0; + + if (req.method === 'GET') { + try { + if (hasUsers) { + return res.status(200).json({ setup: false }); + } + if (!hasUsers && hasRepos) { + return ApiResponse.forbidden( + res, + 'The setup has already been completed, and some repositories are already configured. To reset, please delete the config/repo.json file or rebuild/restore the config/users.json file.' + ); + } + return res.status(200).json({ setup: true }); + } catch (error) { + return ApiResponse.serverError(res, error); + } + } else if (req.method === 'POST') { + try { + if (hasUsers || hasRepos) { + return ApiResponse.notFound(res); + } + + const setupSchema = z.object({ + username: z + .string() + .trim() + .regex(/^[a-z]{1,20}$/, 'Only a-z characters are allowed (1 to 20 char.)'), + email: z.string().trim().email('Invalid email address').optional(), + password: z.string().min(12, 'Password must be at least 12 characters long'), + }); + const parsedData = setupSchema.safeParse(req.body); + if (!parsedData.success) { + return res.status(422).json({ error: parsedData.error.errors[0].message }); + } + + const hashedPassword = await AuthService.hashPassword(parsedData.data.password); + + await ConfigService.updateUsersList([ + { + id: 0, + username: parsedData.data.username, + password: hashedPassword, + email: parsedData.data.email, + roles: ['admin'], + }, + ]); + + return ApiResponse.success(res, 'Setup completed successfully'); + } catch (error) { + return ApiResponse.serverError(res, error); + } + } +} diff --git a/types/domain/config.types.ts b/types/domain/config.types.ts index 6a7ecca..fce10ce 100644 --- a/types/domain/config.types.ts +++ b/types/domain/config.types.ts @@ -23,7 +23,8 @@ export type BorgWarehouseUser = { username: string; password: string; roles: string[]; - email: string; + email?: string; + oauth_emails?: string[]; emailAlert?: boolean; appriseAlert?: boolean; appriseMode?: AppriseModeEnum;