diff --git a/.cursor/rules/error-classes-own-files.mdc b/.cursor/rules/error-classes-own-files.mdc new file mode 100644 index 00000000..ba48a84f --- /dev/null +++ b/.cursor/rules/error-classes-own-files.mdc @@ -0,0 +1,13 @@ +--- +description: Declare each error class in its own file, not beside the type that uses it +globs: "{src,test}/**/*.{ts,js}" +alwaysApply: false +--- + +# Error classes in their own files + +Put every error class in a dedicated file whose name matches the exported type. Do not declare error classes in the same file as a port, adapter, use case, controller, or other primary type that throws or returns them. + +Group those files in an `error` directory next to the module they belong to. Import the error type from that file wherever it is constructed or checked. + +A base error and each specialized subclass are separate files. One public error class per file. diff --git a/.cursor/rules/prefer-classes-over-functions.mdc b/.cursor/rules/prefer-classes-over-functions.mdc new file mode 100644 index 00000000..dc488726 --- /dev/null +++ b/.cursor/rules/prefer-classes-over-functions.mdc @@ -0,0 +1,13 @@ +--- +description: Prefer a class over a standalone function when the unit is a collaborator +globs: "{src,test}/**/*.{ts,js}" +alwaysApply: false +--- + +# Prefer classes over standalone functions + +When adding behavior that other modules will depend on, prefer a class if that unit will be constructed and injected, hold configuration or instance state, implement a port, wrap a vendor library, or group several operations on the same concern. + +Use a standalone function only when the work is a pure transformation or catalog with no collaborators, no configuration to inject, and no reason to substitute an implementation in tests or at the composition root. + +If both shapes would work, choose the class. diff --git a/.cursor/rules/type-and-file-names.mdc b/.cursor/rules/type-and-file-names.mdc index 415914eb..802ad76d 100644 --- a/.cursor/rules/type-and-file-names.mdc +++ b/.cursor/rules/type-and-file-names.mdc @@ -21,9 +21,9 @@ The same conjugation rule applies in Portuguese and in English. Keep the languag | --- | --- | | List a collection | `{Verb}{Resources}Controller` / `{Verb}{Resources}UseCase` | | Get one by id | `{Verb}{Resource}UseCase` (and matching controller) | -| Tests | One file per action; kebab-case of the same verb + resource stem | +| Tests | One file per type: `{TypeName}.test.ts` | -Type files are PascalCase and match the exported type. Test files use kebab-case of that stem (for example `lists-countries.test.ts`), not a different verb and not several actions in one file. +Type files are PascalCase and match the exported type. Test files use the same TitleCase name as the type (for example `ListaPaisesUseCase.test.ts`). Folders use camelCase when the name is compound (for example `src/domain/usuarioSessao`, `src/domain/faseSucessional`). Tests live under the same folder name as `src` (for example `test/unit/domain/usuarioSessao/`). Do not use kebab-case or TitleCase for those folders. ## When renaming diff --git a/.env.example b/.env.example index 94a12608..dbbd176a 100644 --- a/.env.example +++ b/.env.example @@ -9,15 +9,6 @@ CORS_ALLOWED_HEADERS=Content-Type,Authorization STORAGE_PATH=./uploads -MYSQL_DATABASE=herbario_dev -MYSQL_HOST=127.0.0.1 -MYSQL_PORT=3306 -MYSQL_USERNAME=root -MYSQL_PASSWORD=masterkey - -MYSQL_MIGRATION_USERNAME=root -MYSQL_MIGRATION_PASSWORD=masterkey - RECAPTCHA_SECRET_KEY=6LcYYYYYYYYYYYYYY JWT_SECRET=your-jwt-secret-here diff --git a/README.md b/README.md index a67e7c08..dcf9bf68 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,108 @@ # HCF API +HTTP API do sistema do Herbário da UTFPR (HCF). O painel web usa, em desenvolvimento, `PAINEL_BASE_URL=http://localhost:5173`. + ## Pré-requisitos -- [NodeJS >= 22](https://nodejs.org/en) -- [Yarn](https://yarnpkg.com) -- [Docker](https://www.docker.com) -- [Docker Compose](https://docs.docker.com/compose) +- [Node.js 22](https://nodejs.org/) (`.nvmrc` aponta para `lts/jod`; com nvm: `nvm use`) +- [Yarn](https://yarnpkg.com/) (Classic; o repositório usa `yarn.lock`) +- [Docker](https://docs.docker.com/get-docker/) com Compose V2 (`docker compose`) +- Git +- Um cliente para restaurar o dump: [DBeaver](https://dbeaver.io/) ou `psql` + +## Variáveis de ambiente + +Clone o repositório e copie o arquivo de ambiente: + +```shell +cp .env.example .env +``` -## Configuração +Os nomes e os valores padrão locais estão em `.env.example`. Ajuste o `.env` se precisar. Não commite segredos. -Após a instalação dos programas necessários, faça uma cópia do arquivo `.env.example` e renomeie como `.env`. +| Grupo | Variáveis | Uso local | +| --- | --- | --- | +| Runtime | `TZ`, `PORT`, `NODE_ENV`, `STORAGE_PATH` | Os padrões do exemplo bastam. | +| CORS | `CORS_ORIGINS`, `CORS_METHODS`, `CORS_ALLOWED_HEADERS` | `*` ou a origem do painel. | +| Postgres | `PG_DATABASE`, `PG_HOST`, `PG_PORT`, `PG_USERNAME`, `PG_PASSWORD`, `PG_MIGRATION_USERNAME`, `PG_MIGRATION_PASSWORD` | O Compose usa `PG_DATABASE`, `PG_USERNAME`, `PG_PASSWORD` e `PG_PORT`. A API usa `PG_*`. | +| Auth, e-mail, captcha | `JWT_SECRET`, `SMTP_*`, `RECAPTCHA_SECRET_KEY` | Login, troca de senha e reCAPTCHA. | +| Painel | `PAINEL_BASE_URL` | Padrão local: `http://localhost:5173`. | ## Banco de dados -Para subir o banco de dados do projeto, execute no terminal: +Peça um dump a um colega de time (não há dump neste repositório). Suba o PostgreSQL: ```shell -$ docker-compose up mysql +docker compose up postgres ``` -Os dados de conexão do banco de dados estão dentro do arquivo `.env` criado anteriormente. +Conecte com os valores de `PG_*` do `.env` (padrões de `.env.example`: host `127.0.0.1`, porta `5432`, usuário `postgres`, senha `masterkey`, banco `herbario_dev`). -## Execução +Pode usar um cliente gráfico como o DBeaver (importe ou execute o dump na conexão) ou o terminal com `psql`. + +Dump compactado (`.sql.gz`): + +```shell +gunzip -c caminho/para/dump.sql.gz | \ + PGPASSWORD=masterkey psql \ + -h 127.0.0.1 \ + -p 5432 \ + -U postgres \ + -d herbario_dev +``` -No terminal, digite os comandos abaixo: +Dump em SQL puro: ```shell -$ yarn install -$ yarn start +PGPASSWORD=masterkey psql \ + -h 127.0.0.1 \ + -p 5432 \ + -U postgres \ + -d herbario_dev \ + -f caminho/para/dump.sql ``` -Você deve ver uma saída semelhante a demonstração abaixo. +## Execução + +```shell +yarn install +yarn start +``` + +`yarn start` recarrega ao alterar arquivos (`tsx --watch --env-file=.env`). Saída esperada em desenvolvimento: ```txt Using "development" environment Master 18385 is running -Worker 18385 started on port 3000 +Server is running on port 3000 ``` ---- +Confira com `GET http://localhost:3000/health` (`{ "status": "OK" }`). + +## Scripts + +| Comando | Função | +| --- | --- | +| `yarn start` | Servidor de desenvolvimento com watch | +| `yarn build` | Bundle de produção (`dist/`) | +| `yarn lint` | `tsc --noEmit` e ESLint | +| `yarn test` | Todos os projetos Vitest | +| `yarn test:unit` / `yarn test:unit:watch` | Testes unitários | +| `yarn test:integration` / `yarn test:integration:watch` | Testes de integração (Postgres de teste) | +| `yarn test:coverage` | Cobertura dos testes unitários | +| `yarn migration:create` / `yarn migration:apply` | Autores de mudança de schema | -- adicionar busca pelo código de barras da foto -- colocar um alerta no painel quando estiver em ambiente de desenvolvimento -- issue para corrigir o DarwinCore (problema com o botão, quando clica não está funcionando) -- issue para o incremento do hcf na api ao invés do painel -- relatorio de quantidade de tombos por periodo - - relatorio tem que ter um grafico, mostrando quantos tombos foram cadastrados por periodo, somente quantitativo +O `yarn install` configura o Husky. O hook `pre-push` roda os testes unitários. -- alteração na ficha tombo +## Testes + +**Unitários:** `yarn test:unit` — não precisa de Docker. + +**Integração:** sobe um PostgreSQL separado (porta **5433**, banco `herbario_test`, credenciais iguais às de `.env.test`). O schema vem de `test/integration/setup/schema.sql` na inicialização do container. + +```shell +docker compose -f compose.integration.yml up -d +yarn test:integration +``` -nome cientifico = genero, especie, subespecie (se tiver), variedade (se tiver) -um unico tombo tem variedade e subespecie. como devemos exibir no nome cientifico? -inverter a ordem, colocar subespecie e depois variedade +Mais detalhes em `test/integration/README.md`. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index f5f492cf..a5031dd5 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -2,3 +2,9 @@ inventory = inventory roles_path = roles host_key_checking = false +retry_files_enabled = false +interpreter_python = auto_silent +vault_password_file = inventory/.vault_pass + +[ssh_connection] +pipelining = true diff --git a/ansible/group_vars/.gitkeep b/ansible/group_vars/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/ansible/playbooks/04_api_database_access.yml b/ansible/playbooks/04_api_database_access.yml new file mode 100644 index 00000000..5bfa0bfd --- /dev/null +++ b/ansible/playbooks/04_api_database_access.yml @@ -0,0 +1,127 @@ +- name: Setup API database access + hosts: herbario + become: true + + roles: + - postgresql_client + + tasks: + - name: Ensure API PostgreSQL user is present + tags: + - postgres + community.postgresql.postgresql_user: + name: "{{ item.name }}" + password: "{{ item.password }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + state: present + loop: "{{ postgres.api_users }}" + loop_control: + label: "{{ item.name }}" + + - name: Grant CONNECT on the API database + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.database }}" + type: database + privs: CONNECT + roles: "{{ item.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users }}" + loop_control: + label: "{{ item.name }} -> {{ item.database }}" + + - name: Grant USAGE on schemas + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.0.database }}" + type: schema + objs: "{{ item.1 }}" + privs: USAGE + roles: "{{ item.0.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users | product(['public', 'topology']) | list }}" + loop_control: + label: "{{ item.0.name }} {{ item.0.database }}.{{ item.1 }}" + + - name: Grant DML on all tables + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.0.database }}" + schema: "{{ item.1 }}" + type: table + objs: ALL_IN_SCHEMA + privs: SELECT,INSERT,UPDATE,DELETE + roles: "{{ item.0.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users | product(['public', 'topology']) | list }}" + loop_control: + label: "{{ item.0.name }} {{ item.0.database }}.{{ item.1 }}" + + - name: Grant USAGE,SELECT on all sequences + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.0.database }}" + schema: "{{ item.1 }}" + type: sequence + objs: ALL_IN_SCHEMA + privs: USAGE,SELECT + roles: "{{ item.0.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users | product(['public', 'topology']) | list }}" + loop_control: + label: "{{ item.0.name }} {{ item.0.database }}.{{ item.1 }}" + + - name: Set default privileges for future tables + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.0.database }}" + schema: "{{ item.1 }}" + type: default_privs + objs: tables + privs: SELECT,INSERT,UPDATE,DELETE + roles: "{{ item.0.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users | product(['public', 'topology']) | list }}" + loop_control: + label: "{{ item.0.name }} {{ item.0.database }}.{{ item.1 }}" + + - name: Set default privileges for future sequences + tags: + - postgres + community.postgresql.postgresql_privs: + database: "{{ item.0.database }}" + schema: "{{ item.1 }}" + type: default_privs + objs: sequences + privs: USAGE,SELECT + roles: "{{ item.0.name }}" + login_user: "{{ postgres.login_user }}" + login_host: "{{ postgres.login_host }}" + login_port: "{{ postgres.login_port }}" + login_password: "{{ postgres.login_password }}" + loop: "{{ postgres.api_users | product(['public', 'topology']) | list }}" + loop_control: + label: "{{ item.0.name }} {{ item.0.database }}.{{ item.1 }}" diff --git a/package.json b/package.json index 2f353c7c..dd5aed02 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "audit:fix": "npm audit fix" }, "dependencies": { + "@casl/ability": "6.8.0", "axios": "1.13.2", "bcrypt": "6.0.0", "body-parser": "2.2.0", @@ -68,6 +69,7 @@ "@stylistic/eslint-plugin": "5.5.0", "@types/cors": "2.8.19", "@types/express": "5.0.5", + "@types/jsonwebtoken": "9.0.10", "@types/morgan": "1.9.10", "@types/pg": "^8.16.0", "@types/react": "19.2.2", @@ -84,8 +86,8 @@ "globals": "16.5.0", "husky": "9.1.7", "npm-run-all": "4.1.5", - "tsx": "4.21.0", "supertest": "7.2.2", + "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.46.3", "vitest": "4.0.7" diff --git a/src/application/create-app.ts b/src/application/create-app.ts index a3b3901c..90c7b3ae 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -15,10 +15,12 @@ import { generatePreview, reportPreview } from '../reports/controller' import { routes as createEstadoRoutes } from './estado' import { routes as createFaseSucessionalRoutes } from './fase-sucessional' import { routes as createPaisRoutes } from './pais' +import { routes as createSoloRoutes } from './solo' +import { routes as createRelevoRoutes } from './relevo' import { routes as createVegetacaoRoutes } from './vegetacao' interface CorsParameters { - origins: string[] + origins: string | string[] methods: string[] allowedHeaders: string[] } @@ -58,6 +60,8 @@ export function createApp({ const routes: Route[] = [ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), + ...createSoloRoutes(knex), + ...createRelevoRoutes(knex), ...createFaseSucessionalRoutes(knex), ...createVegetacaoRoutes(knex) ] diff --git a/src/application/index.ts b/src/application/index.ts index 9053c948..59c82f39 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -9,7 +9,8 @@ import { createApp } from './create-app' const environment = process.env.NODE_ENV ?? 'development' -const corsOrigins = process.env.CORS_ORIGINS ?? '*' +const corsOriginsRaw = process.env.CORS_ORIGINS ?? '*' +const corsOrigins = corsOriginsRaw === '*' ? '*' : corsOriginsRaw.split(',') const corsMethods = process.env.CORS_METHODS ?? 'HEAD,GET,POST,PUT,PATCH,DELETE' const corsAllowedHeaders = process.env.CORS_ALLOWED_HEADERS ?? 'Content-Type,Authorization' @@ -17,7 +18,7 @@ const logger = new ConsoleLogger() const application = createApp({ logger, cors: { - origins: corsOrigins.split(','), + origins: corsOrigins, methods: corsMethods.split(','), allowedHeaders: corsAllowedHeaders.split(',') }, diff --git a/src/application/relevo/BuscaRelevoController.ts b/src/application/relevo/BuscaRelevoController.ts new file mode 100644 index 00000000..0e39f57c --- /dev/null +++ b/src/application/relevo/BuscaRelevoController.ts @@ -0,0 +1,41 @@ +import { BuscaRelevoPorIdUseCase } from '@/domain/relevo/BuscaRelevoPorIdUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscaRelevoPorIdUseCase: BuscaRelevoPorIdUseCase +} + +export class BuscaRelevoController implements RequestHandler { + private readonly buscaRelevoPorIdUseCase: BuscaRelevoPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscaRelevoPorIdUseCase = dependencies.buscaRelevoPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { relevoId } = request.params as { relevoId?: string } + + if (relevoId === undefined || relevoId === null || relevoId === '' || !/^\d+$/.test(relevoId)) { + return new BadRequestError({ message: 'relevoId inválido' }) + } + + const result = await this.buscaRelevoPorIdUseCase.execute({ id: Number(relevoId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Relevo não encontrado' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/relevo/ListaRelevosController.ts b/src/application/relevo/ListaRelevosController.ts new file mode 100644 index 00000000..f26ecbb9 --- /dev/null +++ b/src/application/relevo/ListaRelevosController.ts @@ -0,0 +1,65 @@ +import { ListaRelevosUseCase } from '@/domain/relevo/ListaRelevosUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaRelevosUseCase: ListaRelevosUseCase +} + +export class ListaRelevosController implements RequestHandler { + private readonly listaRelevosUseCase: ListaRelevosUseCase + + constructor(dependencies: Dependencies) { + this.listaRelevosUseCase = dependencies.listaRelevosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { nome, order } = request.params as { + nome?: string + order?: string + } + + const parsedOrder = parseOrder(order) + if (parsedOrder instanceof Error) { + return new BadRequestError({ message: parsedOrder.message }) + } + + const result = await this.listaRelevosUseCase.execute({ + nome, + order: parsedOrder + }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} + +function parseOrder(order?: string): { column: 'id' | 'nome'; direction: 'asc' | 'desc' } | Error | undefined { + if (!order) return undefined + + const pieces = order.split(':') + if (pieces.length !== 2) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + const [rawColumn, rawDirection] = pieces + const column = rawColumn.trim().toLowerCase() + const direction = rawDirection.trim().toLowerCase() + + if ((column !== 'id' && column !== 'nome') || (direction !== 'asc' && direction !== 'desc')) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + return { + column, + direction + } +} diff --git a/src/application/relevo/index.ts b/src/application/relevo/index.ts new file mode 100644 index 00000000..e7e16999 --- /dev/null +++ b/src/application/relevo/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscaRelevoPorIdUseCase } from '@/domain/relevo/BuscaRelevoPorIdUseCase' +import { ListaRelevosUseCase } from '@/domain/relevo/ListaRelevosUseCase' +import { RelevoCollectionKnexAdapter } from '@/infrastructure/RelevoCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscaRelevoController } from './BuscaRelevoController' +import { ListaRelevosController } from './ListaRelevosController' + +export function routes(knex: Knex): Route[] { + const relevoCollection = new RelevoCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaRelevosController({ + listaRelevosUseCase: new ListaRelevosUseCase({ relevoCollection }) + }) + ], + method: Method.Get, + path: '/v2/relevos' + }, + { + handlers: [ + new BuscaRelevoController({ + buscaRelevoPorIdUseCase: new BuscaRelevoPorIdUseCase({ relevoCollection }) + }) + ], + method: Method.Get, + path: '/v2/relevos/:relevoId' + } + ] +} diff --git a/src/application/solo/BuscaSoloController.ts b/src/application/solo/BuscaSoloController.ts new file mode 100644 index 00000000..b3610add --- /dev/null +++ b/src/application/solo/BuscaSoloController.ts @@ -0,0 +1,41 @@ +import { BuscaSoloPorIdUseCase } from '@/domain/solo/BuscaSoloPorIdUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscaSoloPorIdUseCase: BuscaSoloPorIdUseCase +} + +export class BuscaSoloController implements RequestHandler { + private readonly buscaSoloPorIdUseCase: BuscaSoloPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscaSoloPorIdUseCase = dependencies.buscaSoloPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { soloId } = request.params as { soloId?: string } + + if (soloId === undefined || soloId === null || soloId === '' || !/^\d+$/.test(soloId)) { + return new BadRequestError({ message: 'soloId inválido' }) + } + + const result = await this.buscaSoloPorIdUseCase.execute({ id: Number(soloId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Solo não encontrado' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/solo/BuscarSoloController.ts b/src/application/solo/BuscarSoloController.ts new file mode 100644 index 00000000..fe7352ea --- /dev/null +++ b/src/application/solo/BuscarSoloController.ts @@ -0,0 +1,41 @@ +import { BuscarSoloPorIdUseCase } from '@/domain/solo/BuscarSoloPorIdUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscarSoloPorIdUseCase: BuscarSoloPorIdUseCase +} + +export class BuscarSoloController implements RequestHandler { + private readonly buscarSoloPorIdUseCase: BuscarSoloPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscarSoloPorIdUseCase = dependencies.buscarSoloPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { soloId } = request.params as { soloId?: string } + + if (soloId === undefined || soloId === null || soloId === '' || !/^\d+$/.test(soloId)) { + return new BadRequestError({ message: 'soloId inválido' }) + } + + const result = await this.buscarSoloPorIdUseCase.execute({ id: Number(soloId) }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Solo não encontrado' }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} diff --git a/src/application/solo/ListaSolosController.ts b/src/application/solo/ListaSolosController.ts new file mode 100644 index 00000000..f58c0249 --- /dev/null +++ b/src/application/solo/ListaSolosController.ts @@ -0,0 +1,65 @@ +import { ListaSolosUseCase } from '@/domain/solo/ListaSolosUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaSolosUseCase: ListaSolosUseCase +} + +export class ListaSolosController implements RequestHandler { + private readonly listaSolosUseCase: ListaSolosUseCase + + constructor(dependencies: Dependencies) { + this.listaSolosUseCase = dependencies.listaSolosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { nome, order } = request.params as { + nome?: string + order?: string + } + + const parsedOrder = parseOrder(order) + if (parsedOrder instanceof Error) { + return new BadRequestError({ message: parsedOrder.message }) + } + + const result = await this.listaSolosUseCase.execute({ + nome, + order: parsedOrder + }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { statusCode: StatusCode.Ok, body: result.value } + } +} + +function parseOrder(order?: string): { column: 'id' | 'nome'; direction: 'asc' | 'desc' } | Error | undefined { + if (!order) return undefined + + const pieces = order.split(':') + if (pieces.length !== 2) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + const [rawColumn, rawDirection] = pieces + const column = rawColumn.trim().toLowerCase() + const direction = rawDirection.trim().toLowerCase() + + if ((column !== 'id' && column !== 'nome') || (direction !== 'asc' && direction !== 'desc')) { + return new Error('order inválido. Use o formato "id:asc", "id:desc", "nome:asc" ou "nome:desc"') + } + + return { + column, + direction + } +} diff --git a/src/application/solo/index.ts b/src/application/solo/index.ts new file mode 100644 index 00000000..5e757bff --- /dev/null +++ b/src/application/solo/index.ts @@ -0,0 +1,35 @@ +import { type Knex } from 'knex' + +import { BuscaSoloPorIdUseCase } from '@/domain/solo/BuscaSoloPorIdUseCase' +import { ListaSolosUseCase } from '@/domain/solo/ListaSolosUseCase' +import { SoloCollectionKnexAdapter } from '@/infrastructure/SoloCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { BuscaSoloController } from './BuscaSoloController' +import { ListaSolosController } from './ListaSolosController' + +export function routes(knex: Knex): Route[] { + const soloCollection = new SoloCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new ListaSolosController({ + listaSolosUseCase: new ListaSolosUseCase({ soloCollection }) + }) + ], + method: Method.Get, + path: '/v2/solos' + }, + { + handlers: [ + new BuscaSoloController({ + buscaSoloPorIdUseCase: new BuscaSoloPorIdUseCase({ soloCollection }) + }) + ], + method: Method.Get, + path: '/v2/solos/:soloId' + } + ] +} diff --git a/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts b/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts new file mode 100644 index 00000000..f14d93ca --- /dev/null +++ b/src/database/migration/20260927120000_cria_tabela_usuarios_sessoes.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + await knex.transaction(async trx => { + await trx.schema.createTable('usuarios_sessoes', table => { + table.uuid('id').primary().defaultTo(trx.raw('gen_random_uuid()')) + + table.integer('usuario_id').notNullable() + table.foreign('usuario_id') + .references('id') + .inTable('usuarios') + table.index('usuario_id') + + table.string('refresh_token_hash', 64).notNullable().unique() + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('last_used_at').notNullable() + table.timestamp('expires_at').notNullable() + }) + }) +} diff --git a/src/domain/relevo/BuscaRelevoPorIdUseCase.ts b/src/domain/relevo/BuscaRelevoPorIdUseCase.ts new file mode 100644 index 00000000..7cf4ff15 --- /dev/null +++ b/src/domain/relevo/BuscaRelevoPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' +import { RelevoCollection } from './RelevoCollection' + +interface Dependencies { + relevoCollection: RelevoCollection +} + +export class BuscaRelevoPorIdUseCase { + private readonly relevoCollection: RelevoCollection + + constructor(dependencies: Dependencies) { + this.relevoCollection = dependencies.relevoCollection + } + + execute({ id }: { id: number }): Promise> { + return this.relevoCollection.findById(id) + } +} diff --git a/src/domain/relevo/ListaRelevosUseCase.ts b/src/domain/relevo/ListaRelevosUseCase.ts new file mode 100644 index 00000000..993bab0c --- /dev/null +++ b/src/domain/relevo/ListaRelevosUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' +import { RelevoCollection, RelevoFilters } from './RelevoCollection' + +interface Dependencies { + relevoCollection: RelevoCollection +} + +export class ListaRelevosUseCase { + private readonly relevoCollection: RelevoCollection + + constructor(dependencies: Dependencies) { + this.relevoCollection = dependencies.relevoCollection + } + + execute(filters: RelevoFilters): Promise> { + return this.relevoCollection.findAll(filters) + } +} diff --git a/src/domain/relevo/Relevo.ts b/src/domain/relevo/Relevo.ts new file mode 100644 index 00000000..073ed1b6 --- /dev/null +++ b/src/domain/relevo/Relevo.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class Relevo { + readonly id: number + readonly nome: string + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.nome = attributes.nome + } + + static create(attributes: Attributes): Either { + if (!attributes.nome.trim()) { + return Either.left(new Error('Nome do relevo não pode ser vazio')) + } + + return Either.right(new Relevo(attributes)) + } +} diff --git a/src/domain/relevo/RelevoCollection.ts b/src/domain/relevo/RelevoCollection.ts new file mode 100644 index 00000000..c63f3cdb --- /dev/null +++ b/src/domain/relevo/RelevoCollection.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Relevo' + +export interface RelevoOrder { + column: 'id' | 'nome' + direction: 'asc' | 'desc' +} + +export interface RelevoFilters { + nome?: string + order?: RelevoOrder +} + +export interface RelevoCollection { + findAll(filters: RelevoFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/domain/solo/BuscaSoloPorIdUseCase.ts b/src/domain/solo/BuscaSoloPorIdUseCase.ts new file mode 100644 index 00000000..7f965e8e --- /dev/null +++ b/src/domain/solo/BuscaSoloPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' +import { SoloCollection } from './SoloCollection' + +interface Dependencies { + soloCollection: SoloCollection +} + +export class BuscaSoloPorIdUseCase { + private readonly soloCollection: SoloCollection + + constructor(dependencies: Dependencies) { + this.soloCollection = dependencies.soloCollection + } + + execute({ id }: { id: number }): Promise> { + return this.soloCollection.findById(id) + } +} diff --git a/src/domain/solo/BuscarSoloPorIdUseCase.ts b/src/domain/solo/BuscarSoloPorIdUseCase.ts new file mode 100644 index 00000000..2b7dc018 --- /dev/null +++ b/src/domain/solo/BuscarSoloPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' +import { SoloCollection } from './SoloCollection' + +interface Dependencies { + soloCollection: SoloCollection +} + +export class BuscarSoloPorIdUseCase { + private readonly soloCollection: SoloCollection + + constructor(dependencies: Dependencies) { + this.soloCollection = dependencies.soloCollection + } + + execute({ id }: { id: number }): Promise> { + return this.soloCollection.findById(id) + } +} diff --git a/src/domain/solo/ListaSolosUseCase.ts b/src/domain/solo/ListaSolosUseCase.ts new file mode 100644 index 00000000..a90894e0 --- /dev/null +++ b/src/domain/solo/ListaSolosUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' +import { SoloCollection, SoloFilters } from './SoloCollection' + +interface Dependencies { + soloCollection: SoloCollection +} + +export class ListaSolosUseCase { + private readonly soloCollection: SoloCollection + + constructor(dependencies: Dependencies) { + this.soloCollection = dependencies.soloCollection + } + + execute(filters: SoloFilters): Promise> { + return this.soloCollection.findAll(filters) + } +} diff --git a/src/domain/solo/Solo.ts b/src/domain/solo/Solo.ts new file mode 100644 index 00000000..acc2121a --- /dev/null +++ b/src/domain/solo/Solo.ts @@ -0,0 +1,24 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + nome: string +} + +export class Solo { + readonly id: number + readonly nome: string + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.nome = attributes.nome + } + + static create(attributes: Attributes): Either { + if (!attributes.nome.trim()) { + return Either.left(new Error('Nome do solo não pode ser vazio')) + } + + return Either.right(new Solo(attributes)) + } +} diff --git a/src/domain/solo/SoloCollection.ts b/src/domain/solo/SoloCollection.ts new file mode 100644 index 00000000..37dfe5c0 --- /dev/null +++ b/src/domain/solo/SoloCollection.ts @@ -0,0 +1,18 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Solo' + +export interface SoloOrder { + column: 'id' | 'nome' + direction: 'asc' | 'desc' +} + +export interface SoloFilters { + nome?: string + order?: SoloOrder +} + +export interface SoloCollection { + findAll(filters: SoloFilters): Promise> + findById(id: number): Promise> +} diff --git a/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..e4876fd3 --- /dev/null +++ b/src/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.ts @@ -0,0 +1,19 @@ +import { type Either } from '@/library/either/Either' + +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection +} + +export class ApagaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + } + + async execute(params: { id: string }): Promise> { + return this.usuarioSessaoCollection.deleteById(params.id) + } +} diff --git a/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts b/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts new file mode 100644 index 00000000..dc017472 --- /dev/null +++ b/src/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.ts @@ -0,0 +1,19 @@ +import { type Either } from '@/library/either/Either' + +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection +} + +export class ApagaUsuarioSessoesUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + } + + async execute(params: { usuarioId: number }): Promise> { + return this.usuarioSessaoCollection.deleteByUsuarioId(params.usuarioId) + } +} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts new file mode 100644 index 00000000..3bf76467 --- /dev/null +++ b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.ts @@ -0,0 +1,52 @@ +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + now?: () => Date +} + +export class BuscaUsuarioSessaoPorHashUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { refreshTokenHash: string }): Promise> { + const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(params.refreshTokenHash) + if (found.left()) { + return found + } + if (!found.value) { + return Either.right(null) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.right(null) + } + + return Either.right(found.value) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts new file mode 100644 index 00000000..f64a30b2 --- /dev/null +++ b/src/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.ts @@ -0,0 +1,52 @@ +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + now?: () => Date +} + +export class BuscaUsuarioSessaoPorIdUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { id: string }): Promise> { + const found = await this.usuarioSessaoCollection.findById(params.id) + if (found.left()) { + return found + } + if (!found.value) { + return Either.right(null) + } + + const expired = await this.deleteIfExpired(found.value) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.right(null) + } + + return Either.right(found.value) + } + + private async deleteIfExpired(session: Attributes): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, this.now())) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..11b59d47 --- /dev/null +++ b/src/domain/usuarioSessao/CriaUsuarioSessaoUseCase.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto' + +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + refreshToken: RefreshToken + now?: () => Date +} + +export class CriaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly refreshToken: RefreshToken + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.refreshToken = dependencies.refreshToken + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { usuarioId: number }): Promise> { + const generated = this.refreshToken.generate() + if (generated.left()) { + return generated + } + + const createdAt = this.now() + const attributes: Attributes = { + id: randomUUID(), + usuarioId: params.usuarioId, + refreshTokenHash: generated.value.hash, + createdAt, + lastUsedAt: createdAt, + expiresAt: UsuarioSessao.refreshExpiresAt(createdAt) + } + + const session = UsuarioSessao.create(attributes) + if (session.left()) { + return session + } + + const persisted = await this.usuarioSessaoCollection.create({ + id: session.value.id, + usuarioId: session.value.usuarioId, + refreshTokenHash: session.value.refreshTokenHash, + createdAt: session.value.createdAt, + lastUsedAt: session.value.lastUsedAt, + expiresAt: session.value.expiresAt + }) + if (persisted.left()) { + return persisted + } + + return Either.right({ + session: persisted.value, + refreshToken: generated.value.token + }) + } +} diff --git a/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts b/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts new file mode 100644 index 00000000..2d195fad --- /dev/null +++ b/src/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.ts @@ -0,0 +1,83 @@ +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { UserSessionNotFoundError } from './error/UserSessionNotFoundError' +import { type Attributes, UsuarioSessao } from './UsuarioSessao' +import { type UsuarioSessaoCollection } from './UsuarioSessaoCollection' + +interface Dependencies { + usuarioSessaoCollection: UsuarioSessaoCollection + refreshToken: RefreshToken + now?: () => Date +} + +export class RotacionaUsuarioSessaoUseCase { + private readonly usuarioSessaoCollection: UsuarioSessaoCollection + private readonly refreshToken: RefreshToken + private readonly now: () => Date + + constructor(dependencies: Dependencies) { + this.usuarioSessaoCollection = dependencies.usuarioSessaoCollection + this.refreshToken = dependencies.refreshToken + this.now = dependencies.now ?? (() => new Date()) + } + + async execute(params: { refreshToken: string }): Promise> { + const hashed = this.refreshToken.hash(params.refreshToken) + if (hashed.left()) { + return hashed + } + + const found = await this.usuarioSessaoCollection.findByRefreshTokenHash(hashed.value) + if (found.left()) { + return found + } + if (!found.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const now = this.now() + const expired = await this.deleteIfExpired(found.value, now) + if (expired.left()) { + return expired + } + if (expired.value) { + return Either.left(new UserSessionNotFoundError()) + } + + const generated = this.refreshToken.generate() + if (generated.left()) { + return generated + } + + const updated = await this.usuarioSessaoCollection.updateRotation(found.value.id, { + refreshTokenHash: generated.value.hash, + lastUsedAt: now, + expiresAt: UsuarioSessao.refreshExpiresAt(now) + }) + if (updated.left()) { + return updated + } + if (!updated.value) { + return Either.left(new UserSessionNotFoundError()) + } + + return Either.right({ + session: updated.value, + refreshToken: generated.value.token + }) + } + + private async deleteIfExpired(session: Attributes, now: Date): Promise> { + if (!UsuarioSessao.expired(session.expiresAt, now)) { + return Either.right(false) + } + + const deleted = await this.usuarioSessaoCollection.deleteById(session.id) + if (deleted.left()) { + return deleted + } + + return Either.right(true) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessao.ts b/src/domain/usuarioSessao/UsuarioSessao.ts new file mode 100644 index 00000000..7e64eb7a --- /dev/null +++ b/src/domain/usuarioSessao/UsuarioSessao.ts @@ -0,0 +1,62 @@ +import { Either } from '@/library/either/Either' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const REFRESH_TOKEN_HASH_PATTERN = /^[0-9a-f]{64}$/ +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + +export interface Attributes { + id: string + usuarioId: number + refreshTokenHash: string + createdAt: Date + lastUsedAt: Date + expiresAt: Date +} + +export class UsuarioSessao { + static readonly REFRESH_TTL_DAYS = 30 + + readonly id: string + readonly usuarioId: number + readonly refreshTokenHash: string + readonly createdAt: Date + readonly lastUsedAt: Date + readonly expiresAt: Date + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.usuarioId = attributes.usuarioId + this.refreshTokenHash = attributes.refreshTokenHash + this.createdAt = attributes.createdAt + this.lastUsedAt = attributes.lastUsedAt + this.expiresAt = attributes.expiresAt + } + + static refreshExpiresAt(now: Date): Date { + return new Date(now.getTime() + UsuarioSessao.REFRESH_TTL_DAYS * MILLISECONDS_PER_DAY) + } + + static expired(expiresAt: Date, now: Date): boolean { + return expiresAt.getTime() <= now.getTime() + } + + static create(attributes: Attributes): Either { + if (!UUID_PATTERN.test(attributes.id)) { + return Either.left(new Error('Id da sessão deve ser um UUID')) + } + + if (!Number.isInteger(attributes.usuarioId) || attributes.usuarioId <= 0) { + return Either.left(new Error('usuarioId da sessão deve ser um inteiro positivo')) + } + + if (!REFRESH_TOKEN_HASH_PATTERN.test(attributes.refreshTokenHash)) { + return Either.left(new Error('refreshTokenHash da sessão deve ser SHA-256 hex')) + } + + if (attributes.expiresAt.getTime() <= attributes.createdAt.getTime()) { + return Either.left(new Error('expiresAt da sessão deve ser posterior a createdAt')) + } + + return Either.right(new UsuarioSessao(attributes)) + } +} diff --git a/src/domain/usuarioSessao/UsuarioSessaoCollection.ts b/src/domain/usuarioSessao/UsuarioSessaoCollection.ts new file mode 100644 index 00000000..38a715db --- /dev/null +++ b/src/domain/usuarioSessao/UsuarioSessaoCollection.ts @@ -0,0 +1,18 @@ +import { type Either } from '@/library/either/Either' + +import { type Attributes } from './UsuarioSessao' + +export interface RotationUpdate { + refreshTokenHash: string + lastUsedAt: Date + expiresAt: Date +} + +export interface UsuarioSessaoCollection { + create(attributes: Attributes): Promise> + findById(id: string): Promise> + findByRefreshTokenHash(hash: string): Promise> + updateRotation(id: string, update: RotationUpdate): Promise> + deleteById(id: string): Promise> + deleteByUsuarioId(usuarioId: number): Promise> +} diff --git a/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts b/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts new file mode 100644 index 00000000..15bc5c9a --- /dev/null +++ b/src/domain/usuarioSessao/error/UserSessionNotFoundError.ts @@ -0,0 +1,10 @@ +import { BaseError } from '@/library/BaseError' + +export class UserSessionNotFoundError extends BaseError { + constructor(params?: { cause?: unknown }) { + super({ + message: 'User session was not found', + cause: params?.cause + }) + } +} diff --git a/src/factory/UsuarioSessaoCollectionFactory.ts b/src/factory/UsuarioSessaoCollectionFactory.ts new file mode 100644 index 00000000..1dbdb273 --- /dev/null +++ b/src/factory/UsuarioSessaoCollectionFactory.ts @@ -0,0 +1,8 @@ +import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' +import { singleton } from '@/library/singleton' + +import { createKnexInstance } from './KnexFactory' + +export const createUsuarioSessaoCollection = singleton(() => { + return new UsuarioSessaoCollectionKnexAdapter({ knex: createKnexInstance() }) +}) diff --git a/src/helpers/coordenadas.js b/src/helpers/coordenadas.js index 971ee801..bb98c0c1 100644 --- a/src/helpers/coordenadas.js +++ b/src/helpers/coordenadas.js @@ -171,34 +171,43 @@ export const converteDecimalParaGMSSinal = (decimal, isLat) => { }; export const converteDecimalParaGrausMinutosSegundos = (gDec, ehLat, formatada) => { - let graus; - let minutos; - let aux; - let segundos; - let direcao; + const sinal = gDec < 0 ? -1 : 1; + const abs = Math.abs(gDec); - graus = parseInt(gDec); - aux = (graus - gDec) * 60; - minutos = parseInt(aux); - aux = (aux - minutos) * 60; - segundos = aux; + let graus = Math.floor(abs); + const minutosDecimal = (abs - graus) * 60; + let minutos = Math.floor(minutosDecimal); + const segundosRaw = (minutosDecimal - minutos) * 60; + // Arredonda para 2 casas decimais e propaga carry se segundos >= 60 + // (evita exibicao de "60.00" causada por erro de ponto flutuante) + let segundos = Math.round(segundosRaw * 100) / 100; + if (segundos >= 60) { + segundos = 0; + minutos += 1; + } + if (minutos >= 60) { + minutos = 0; + graus += 1; + } + + let direcao; if (ehLat) { - direcao = graus < 0 ? 'S' : 'N'; + direcao = sinal < 0 ? 'S' : 'N'; } else { - direcao = graus < 0 ? 'W' : 'E'; + direcao = sinal < 0 ? 'W' : 'E'; } + const segundosStr = segundos.toFixed(2).replace('.', ','); + if (formatada) { - return `${Math.abs(graus)}°${Math.abs(minutos)}'${Math.abs(segundos).toFixed(2) - .replace('.', ',')}" ${direcao}`; + return `${graus}\u00B0${minutos}'${segundosStr}" ${direcao}`; } return { - graus: Math.abs(graus), - minutos: Math.abs(minutos), - segundos: Math.abs(segundos).toFixed(2) - .replace('.', ','), + graus, + minutos, + segundos: segundosStr, direcao, }; }; diff --git a/src/infrastructure/RelevoCollectionKnexAdapter.ts b/src/infrastructure/RelevoCollectionKnexAdapter.ts new file mode 100644 index 00000000..ba4581d5 --- /dev/null +++ b/src/infrastructure/RelevoCollectionKnexAdapter.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/relevo/Relevo' +import { RelevoCollection, RelevoFilters } from '@/domain/relevo/RelevoCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class RelevoCollectionKnexAdapter implements RelevoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: RelevoFilters): Promise> { + try { + const query = this.knex('relevos') + .select(['id', 'nome']) + + if (filters.nome) { + query.whereILike('nome', `%${filters.nome}%`) + } + + const order = filters.order ?? { column: 'id', direction: 'desc' } + query.orderBy(order.column, order.direction) + + return Either.right(await query) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list relevos', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const relevo = await this.knex('relevos').select(['id', 'nome']).where({ id }).first() + return Either.right(relevo ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find relevo', cause: error })) + } + } +} diff --git a/src/infrastructure/SoloCollectionKnexAdapter.ts b/src/infrastructure/SoloCollectionKnexAdapter.ts new file mode 100644 index 00000000..92e40015 --- /dev/null +++ b/src/infrastructure/SoloCollectionKnexAdapter.ts @@ -0,0 +1,46 @@ +import { Knex } from 'knex' + +import { Attributes } from '@/domain/solo/Solo' +import { SoloCollection, SoloFilters } from '@/domain/solo/SoloCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +export class SoloCollectionKnexAdapter implements SoloCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: SoloFilters): Promise> { + try { + const query = this.knex('solos') + .select(['id', 'nome']) + + if (filters.nome) { + query.whereILike('nome', `%${filters.nome}%`) + } + + const order = filters.order ?? { column: 'id', direction: 'desc' } + query.orderBy(order.column, order.direction) + + return Either.right(await query) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list solos', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const solo = await this.knex('solos').select(['id', 'nome']).where({ id }).first() + return Either.right(solo ?? null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find solo', cause: error })) + } + } +} diff --git a/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts b/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts new file mode 100644 index 00000000..b1a0e628 --- /dev/null +++ b/src/infrastructure/UsuarioSessaoCollectionKnexAdapter.ts @@ -0,0 +1,157 @@ +import { Knex } from 'knex' + +import { type Attributes } from '@/domain/usuarioSessao/UsuarioSessao' +import { + type RotationUpdate, + type UsuarioSessaoCollection +} from '@/domain/usuarioSessao/UsuarioSessaoCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' + +interface Dependencies { + knex: Knex +} + +interface Row { + id: string + usuario_id: number + refresh_token_hash: string + created_at: Date + last_used_at: Date + expires_at: Date +} + +const COLUMNS: Array = [ + 'id', + 'usuario_id', + 'refresh_token_hash', + 'created_at', + 'last_used_at', + 'expires_at' +] + +const TABLE = 'usuarios_sessoes' + +export class UsuarioSessaoCollectionKnexAdapter implements UsuarioSessaoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async create(attributes: Attributes): Promise> { + try { + const rows = await this.knex(TABLE) + .insert({ + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt + }) + .returning(COLUMNS) + + const row = rows[0] + if (!row) { + return Either.left(new CollectionError({ message: 'Failed to create usuário sessão' })) + } + + return Either.right(toAttributes(row)) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to create usuário sessão', + cause: error + })) + } + } + + async findById(id: string): Promise> { + try { + const row = await this.knex(TABLE) + .select(COLUMNS) + .where({ id }) + .first() + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário sessão by id', + cause: error + })) + } + } + + async findByRefreshTokenHash(hash: string): Promise> { + try { + const row = await this.knex(TABLE) + .select(COLUMNS) + .where({ refresh_token_hash: hash }) + .first() + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to find usuário sessão by refresh token hash', + cause: error + })) + } + } + + async updateRotation(id: string, update: RotationUpdate): Promise> { + try { + const rows = await this.knex(TABLE) + .where({ id }) + .update({ + refresh_token_hash: update.refreshTokenHash, + last_used_at: update.lastUsedAt, + expires_at: update.expiresAt + }) + .returning(COLUMNS) + + const row = rows[0] + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to rotate usuário sessão', + cause: error + })) + } + } + + async deleteById(id: string): Promise> { + try { + await this.knex(TABLE).where({ id }).delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to delete usuário sessão', + cause: error + })) + } + } + + async deleteByUsuarioId(usuarioId: number): Promise> { + try { + await this.knex(TABLE).where({ usuario_id: usuarioId }).delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ + message: 'Failed to delete usuário sessões', + cause: error + })) + } + } +} + +function toAttributes(row: Row): Attributes { + return { + id: row.id, + usuarioId: row.usuario_id, + refreshTokenHash: row.refresh_token_hash, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + expiresAt: row.expires_at + } +} diff --git a/src/infrastructure/auth/CryptoRefreshToken.ts b/src/infrastructure/auth/CryptoRefreshToken.ts new file mode 100644 index 00000000..65c31fd2 --- /dev/null +++ b/src/infrastructure/auth/CryptoRefreshToken.ts @@ -0,0 +1,38 @@ +import { createHash, randomBytes } from 'node:crypto' + +import { RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +export class CryptoRefreshToken implements RefreshToken { + generate(): Either { + try { + const token = randomBytes(32).toString('base64url') + const hashed = this.hash(token) + if (hashed.left()) { + return hashed + } + + return Either.right({ + token, + hash: hashed.value + }) + } catch (error) { + return Either.left(new RefreshTokenError({ + message: 'Failed to generate refresh token', + cause: error + })) + } + } + + hash(token: string): Either { + try { + return Either.right(createHash('sha256').update(token, 'utf8').digest('hex')) + } catch (error) { + return Either.left(new RefreshTokenError({ + message: 'Failed to hash refresh token', + cause: error + })) + } + } +} diff --git a/src/infrastructure/auth/JwtAccessToken.ts b/src/infrastructure/auth/JwtAccessToken.ts new file mode 100644 index 00000000..968ca247 --- /dev/null +++ b/src/infrastructure/auth/JwtAccessToken.ts @@ -0,0 +1,101 @@ +import jwt from 'jsonwebtoken' + +import { type AccessPayload, type AccessToken } from '@/library/auth/AccessToken' +import { AccessTokenError } from '@/library/auth/error/AccessTokenError' +import { AccessTokenExpiredError } from '@/library/auth/error/AccessTokenExpiredError' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' +import { Either } from '@/library/either/Either' + +const ACCESS_EXPIRES_IN = '15m' +const ACCESS_TYP = 'access' +const ACCESS_ALGORITHM = 'HS256' + +export class JwtAccessToken implements AccessToken { + private readonly secret: string + + constructor(params: { secret: string }) { + if (!params.secret) { + throw new Error('JWT secret is not set') + } + + this.secret = params.secret + } + + sign(params: { sub: number; sid: string; role: number }): Either { + try { + return Either.right(jwt.sign( + { + sub: String(params.sub), + sid: params.sid, + typ: ACCESS_TYP, + role: params.role + }, + this.secret, + { + algorithm: ACCESS_ALGORITHM, + expiresIn: ACCESS_EXPIRES_IN + } + )) + } catch (error) { + return Either.left(new AccessTokenError({ + message: 'Failed to sign access token', + cause: error + })) + } + } + + verify(token: string): Either { + try { + const decoded = jwt.verify(token, this.secret, { + algorithms: [ACCESS_ALGORITHM] + }) + if (typeof decoded === 'string') { + return Either.left(new AccessTokenInvalidError()) + } + + const payload = this.parseAccessPayload(decoded) + if (!payload) { + return Either.left(new AccessTokenInvalidError()) + } + + return Either.right(payload) + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return Either.left(new AccessTokenExpiredError({ cause: error })) + } + return Either.left(new AccessTokenInvalidError({ cause: error })) + } + } + + private parseAccessPayload(decoded: jwt.JwtPayload): AccessPayload | undefined { + const claims: Record = decoded + const sub = Number(decoded.sub) + const sid = claims.sid + const typ = claims.typ + const role = claims.role + const iat = claims.iat + const exp = claims.exp + + if ( + !Number.isInteger(sub) + || typeof sid !== 'string' + || sid.length === 0 + || typ !== ACCESS_TYP + || typeof role !== 'number' + || !Number.isInteger(role) + || typeof iat !== 'number' + || typeof exp !== 'number' + ) { + return undefined + } + + return { + sub, + sid, + typ: ACCESS_TYP, + role, + iat, + exp + } + } +} diff --git a/src/library/auth/AccessToken.ts b/src/library/auth/AccessToken.ts new file mode 100644 index 00000000..4cf157fc --- /dev/null +++ b/src/library/auth/AccessToken.ts @@ -0,0 +1,16 @@ +import { type AccessTokenError } from '@/library/auth/error/AccessTokenError' +import { type Either } from '@/library/either/Either' + +export interface AccessPayload { + sub: number + sid: string + typ: 'access' + role: number + iat: number + exp: number +} + +export interface AccessToken { + sign(params: { sub: number; sid: string; role: number }): Either + verify(token: string): Either +} diff --git a/src/library/auth/Manager.ts b/src/library/auth/Manager.ts new file mode 100644 index 00000000..3229273c --- /dev/null +++ b/src/library/auth/Manager.ts @@ -0,0 +1,47 @@ +import { + AbilityBuilder, + createMongoAbility, + subject, + type MongoAbility +} from '@casl/ability' + +export interface Rule { + action: A | A[] + resource: R + conditions?: object +} + +export class Manager { + private readonly ability: MongoAbility + + readonly rules: Rule[] + + constructor(params: { rules: Rule[] }) { + const builder = new AbilityBuilder(createMongoAbility) + + for (const rule of params.rules) { + builder.can(rule.action as string, rule.resource as string, rule.conditions) + } + + this.ability = builder.build() + this.rules = params.rules + } + + can(action: A, resource: R, record?: object): boolean { + if (record) { + return this.ability.can(action, subject(resource, record)) + } + + return this.ability.can(action, resource) + } + + canAny(actions: A[], resource: R, record?: object): boolean { + if (!actions.length) return false + return actions.some(action => this.can(action, resource, record)) + } + + canAll(actions: A[], resource: R, record?: object): boolean { + if (!actions.length) return false + return actions.every(action => this.can(action, resource, record)) + } +} diff --git a/src/library/auth/RefreshToken.ts b/src/library/auth/RefreshToken.ts new file mode 100644 index 00000000..903e27ec --- /dev/null +++ b/src/library/auth/RefreshToken.ts @@ -0,0 +1,7 @@ +import { type RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type Either } from '@/library/either/Either' + +export interface RefreshToken { + generate(): Either + hash(token: string): Either +} diff --git a/src/library/auth/createRules.ts b/src/library/auth/createRules.ts new file mode 100644 index 00000000..a80e587b --- /dev/null +++ b/src/library/auth/createRules.ts @@ -0,0 +1,45 @@ +import { type Rule } from './Manager' + +export const ACTIONS = [ + 'read', + 'create', + 'update', + 'delete', + 'export', + 'approve' +] as const + +export const RESOURCES = [ + 'Tombo', + 'Reino', + 'Familia', + 'Subfamilia', + 'Genero', + 'Especie', + 'Subespecie', + 'Variedade', + 'Autor', + 'Pais', + 'Estado', + 'Cidade', + 'Usuario', + 'Identificador', + 'Herbario', + 'Coletor', + 'Reflora', + 'SpeciesLink', + 'Pendencia', + 'Remessa', + 'Upload', + 'Relatorio', + 'Local', + 'Darwin', + 'Splink' +] as const + +export type Action = typeof ACTIONS[number] +export type Resource = typeof RESOURCES[number] + +export function createRules(_user: { id: number; tipo_usuario_id: number }): Rule[] { + return [] +} diff --git a/src/library/auth/error/AccessTokenError.ts b/src/library/auth/error/AccessTokenError.ts new file mode 100644 index 00000000..e8a063be --- /dev/null +++ b/src/library/auth/error/AccessTokenError.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/library/BaseError' + +export class AccessTokenError extends BaseError {} diff --git a/src/library/auth/error/AccessTokenExpiredError.ts b/src/library/auth/error/AccessTokenExpiredError.ts new file mode 100644 index 00000000..e5121bc7 --- /dev/null +++ b/src/library/auth/error/AccessTokenExpiredError.ts @@ -0,0 +1,10 @@ +import { AccessTokenError } from './AccessTokenError' + +export class AccessTokenExpiredError extends AccessTokenError { + constructor(params: { cause?: unknown } = {}) { + super({ + message: 'Access token expired', + cause: params.cause + }) + } +} diff --git a/src/library/auth/error/AccessTokenInvalidError.ts b/src/library/auth/error/AccessTokenInvalidError.ts new file mode 100644 index 00000000..3914141d --- /dev/null +++ b/src/library/auth/error/AccessTokenInvalidError.ts @@ -0,0 +1,10 @@ +import { AccessTokenError } from './AccessTokenError' + +export class AccessTokenInvalidError extends AccessTokenError { + constructor(params: { cause?: unknown } = {}) { + super({ + message: 'Access token is invalid', + cause: params.cause + }) + } +} diff --git a/src/library/auth/error/RefreshTokenError.ts b/src/library/auth/error/RefreshTokenError.ts new file mode 100644 index 00000000..baab47a6 --- /dev/null +++ b/src/library/auth/error/RefreshTokenError.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/library/BaseError' + +export class RefreshTokenError extends BaseError {} diff --git a/src/library/http/error/ForbiddenError.ts b/src/library/http/error/ForbiddenError.ts new file mode 100644 index 00000000..2c0b43da --- /dev/null +++ b/src/library/http/error/ForbiddenError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class ForbiddenError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 403 }) + } +} diff --git a/test/integration/relevo/busca-relevos.test.ts b/test/integration/relevo/busca-relevos.test.ts new file mode 100644 index 00000000..582638e4 --- /dev/null +++ b/test/integration/relevo/busca-relevos.test.ts @@ -0,0 +1,42 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Relevo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/relevos/:relevoId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const [relevo] = await knex('relevos') + .insert({ nome: 'XREL Relevo Encontrado' }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos/${relevo.id}`).expect(200) + expect(response.body).toEqual({ id: relevo.id, nome: relevo.nome }) + } finally { + await knex('relevos').where({ id: relevo.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/relevos/999999').expect(404) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/não encontrad[ao]|not found/i) + }) + + test('retorna 400 para id inválido', async () => { + const response = await agent.get('/api/v2/relevos/abc').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) +}) diff --git a/test/integration/relevo/lista-relevos.test.ts b/test/integration/relevo/lista-relevos.test.ts new file mode 100644 index 00000000..dd3912f7 --- /dev/null +++ b/test/integration/relevo/lista-relevos.test.ts @@ -0,0 +1,98 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Relevo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/relevos', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna a lista ordenada por id decrescente como padrão dentro do prefixo do teste', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Plano`, + `${prefix} Inclinado`, + `${prefix} Ondulado` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix}`).expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Plano`, + `${prefix} Inclinado`, + `${prefix} Ondulado` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix} plano`).expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === `${prefix} Plano`)) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + const inserted = await knex('relevos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get(`/api/v2/relevos?nome=${prefix}&order=nome:asc`).expect(200) + expect(byNameAsc.body).toEqual([...inserted].sort((a, b) => a.nome.localeCompare(b.nome))) + + const byIdAsc = await agent.get(`/api/v2/relevos?nome=${prefix}&order=id:asc`).expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) + + test('retorna 400 quando a ordenação é inválida', async () => { + const prefix = `XREL-${Date.now()}` + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + await knex('relevos').insert(nomes.map(nome => ({ nome }))) + + try { + const response = await agent.get(`/api/v2/relevos?nome=${prefix}&order=foo:bar`).expect(400) + const body = response.body as { error: { message: string } } + expect(body.error.message).toMatch(/inválido|invalid/i) + } finally { + await knex('relevos').whereIn('nome', nomes).delete() + } + }) +}) diff --git a/test/integration/solo/busca-solos.test.ts b/test/integration/solo/busca-solos.test.ts new file mode 100644 index 00000000..9a77b690 --- /dev/null +++ b/test/integration/solo/busca-solos.test.ts @@ -0,0 +1,42 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Solo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/solos/:soloId', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna o registro encontrado', async () => { + const [solo] = await knex('solos') + .insert({ nome: 'XSOL Solo Encontrado' }) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/solos/${solo.id}`).expect(200) + expect(response.body).toEqual({ id: solo.id, nome: solo.nome }) + } finally { + await knex('solos').where({ id: solo.id }).delete() + } + }) + + test('retorna 404 para id inexistente', async () => { + const response = await agent.get('/api/v2/solos/999999').expect(404) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/não encontrad[ao]|not found/i) + }) + + test('retorna 400 para id inválido', async () => { + const response = await agent.get('/api/v2/solos/abc').expect(400) + const body = response.body as { error: { message: string } } + + expect(body.error.message).toMatch(/inválido|invalid/i) + }) +}) diff --git a/test/integration/solo/lista-solos.test.ts b/test/integration/solo/lista-solos.test.ts new file mode 100644 index 00000000..382e02b2 --- /dev/null +++ b/test/integration/solo/lista-solos.test.ts @@ -0,0 +1,96 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' + +type Solo = { id: number; nome: string } + +const returning = ['id', 'nome'] as const + +describe('GET /api/v2/solos', () => { + const { agent, knex } = createTestApp() + + afterAll(() => knex.destroy()) + + test('retorna a lista ordenada por id decrescente como padrão dentro do prefixo do teste', async () => { + const prefix = 'XSOL' + const nomes = [ + `${prefix} Arenoso`, + `${prefix} Argiloso`, + `${prefix} Pedregoso` + ] + + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/solos?nome=${prefix}`).expect(200) + const expected = [...inserted].sort((a, b) => b.id - a.id) + expect(response.body).toEqual(expected) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) + + test('filtra por nome sem diferenciar maiúsculas e minúsculas', async () => { + const prefix = 'XSOL' + const nomes = [ + `${prefix} Arenoso`, + `${prefix} Argiloso`, + `${prefix} Pedregoso` + ] + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const response = await agent.get(`/api/v2/solos?nome=${prefix} arenoso`).expect(200) + expect(response.body).toEqual(inserted.filter(item => item.nome === `${prefix} Arenoso`)) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) + + test('aceita ordenação customizada por nome e id', async () => { + const prefix = 'XSOL' + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + const inserted = await knex('solos') + .insert(nomes.map(nome => ({ nome }))) + .returning(returning) + + try { + const byNameAsc = await agent.get(`/api/v2/solos?nome=${prefix}&order=nome:asc`).expect(200) + expect(byNameAsc.body).toEqual([...inserted].sort((a, b) => a.nome.localeCompare(b.nome))) + + const byIdAsc = await agent.get(`/api/v2/solos?nome=${prefix}&order=id:asc`).expect(200) + expect(byIdAsc.body).toEqual([...inserted].sort((a, b) => a.id - b.id)) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) + + test('retorna 400 quando a ordenação é inválida', async () => { + const prefix = 'XSOL' + const nomes = [ + `${prefix} Z`, + `${prefix} A`, + `${prefix} M` + ] + + await knex('solos').insert(nomes.map(nome => ({ nome }))) + + try { + const response = await agent.get(`/api/v2/solos?nome=${prefix}&order=foo:bar`).expect(400) + const body = response.body as { error: { message: string } } + expect(body.error.message).toMatch(/inválido|invalid/i) + } finally { + await knex('solos').whereIn('nome', nomes).delete() + } + }) +}) diff --git a/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..b9ebadfa --- /dev/null +++ b/test/unit/domain/usuarioSessao/ApagaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,33 @@ +import { + describe, expect, test +} from 'vitest' + +import { ApagaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessaoUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +describe('ApagaUsuarioSessaoUseCase', () => { + test('deletes the session by id', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession() + await collection.create(session) + + const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(collection.rows.has(session.id)).toBe(false) + }) + + test('succeeds when the id is already missing', async () => { + const collection = new FakeUsuarioSessaoCollection() + const useCase = new ApagaUsuarioSessaoUseCase({ usuarioSessaoCollection: collection }) + + const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) + + expect(result.right()).toBe(true) + }) +}) diff --git a/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts b/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts new file mode 100644 index 00000000..4e1ef22f --- /dev/null +++ b/test/unit/domain/usuarioSessao/ApagaUsuarioSessoesUseCase.test.ts @@ -0,0 +1,37 @@ +import { + describe, expect, test +} from 'vitest' + +import { ApagaUsuarioSessoesUseCase } from '@/domain/usuarioSessao/ApagaUsuarioSessoesUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +describe('ApagaUsuarioSessoesUseCase', () => { + test('deletes every session for the usuario', async () => { + const collection = new FakeUsuarioSessaoCollection() + await collection.create(makeSession({ + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 3 + })) + await collection.create(makeSession({ + id: '22222222-2222-4222-8222-222222222222', + usuarioId: 3, + refreshTokenHash: 'e'.repeat(64) + })) + await collection.create(makeSession({ + id: '33333333-3333-4333-8333-333333333333', + usuarioId: 9, + refreshTokenHash: 'f'.repeat(64) + })) + + const useCase = new ApagaUsuarioSessoesUseCase({ usuarioSessaoCollection: collection }) + const result = await useCase.execute({ usuarioId: 3 }) + + expect(result.right()).toBe(true) + expect(collection.rows.size).toBe(1) + expect(collection.rows.has('33333333-3333-4333-8333-333333333333')).toBe(true) + }) +}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts new file mode 100644 index 00000000..0497a4f6 --- /dev/null +++ b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase.test.ts @@ -0,0 +1,64 @@ +import { + describe, expect, test +} from 'vitest' + +import { BuscaUsuarioSessaoPorHashUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorHashUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const NOW = new Date('2026-09-27T12:00:00.000Z') +const HASH = 'a'.repeat(64) + +describe('BuscaUsuarioSessaoPorHashUseCase', () => { + test('returns the session when it is still valid', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ + refreshTokenHash: HASH, + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ refreshTokenHash: HASH }) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ id: session.id }) + }) + + test('returns null when the hash is missing', async () => { + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshTokenHash: '0'.repeat(64) }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deletes an expired session and returns null', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ + refreshTokenHash: HASH, + expiresAt: new Date('2026-09-27T11:59:59.000Z') + }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorHashUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ refreshTokenHash: HASH }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.rows.has(session.id)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts new file mode 100644 index 00000000..0953fa00 --- /dev/null +++ b/test/unit/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase.test.ts @@ -0,0 +1,57 @@ +import { + describe, expect, test +} from 'vitest' + +import { BuscaUsuarioSessaoPorIdUseCase } from '@/domain/usuarioSessao/BuscaUsuarioSessaoPorIdUseCase' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const NOW = new Date('2026-09-27T12:00:00.000Z') + +describe('BuscaUsuarioSessaoPorIdUseCase', () => { + test('returns the session when it is still valid', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ expiresAt: new Date('2026-10-01T00:00:00.000Z') }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ id: session.id }) + }) + + test('returns null when the session is missing', async () => { + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + now: () => NOW + }) + + const result = await useCase.execute({ id: '00000000-0000-4000-8000-000000000000' }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deletes an expired session and returns null', async () => { + const collection = new FakeUsuarioSessaoCollection() + const session = makeSession({ expiresAt: new Date('2026-09-27T11:59:59.000Z') }) + await collection.create(session) + + const useCase = new BuscaUsuarioSessaoPorIdUseCase({ + usuarioSessaoCollection: collection, + now: () => NOW + }) + const result = await useCase.execute({ id: session.id }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.rows.has(session.id)).toBe(false) + }) +}) diff --git a/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..0ceab1f3 --- /dev/null +++ b/test/unit/domain/usuarioSessao/CriaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,69 @@ +import { + describe, expect, test +} from 'vitest' + +import { CriaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/CriaUsuarioSessaoUseCase' +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { RefreshTokenError } from '@/library/auth/error/RefreshTokenError' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { FakeUsuarioSessaoCollection } from './FakeUsuarioSessaoCollection' + +const HASH = 'b'.repeat(64) +const TOKEN = 'opaque-refresh' +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: TOKEN, + hash: HASH + }), + hash: token => Either.right(`${token}-hashed`) + } +} + +describe('CriaUsuarioSessaoUseCase', () => { + test('creates a session with generated id, hash, and 30-day expiry', async () => { + const collection = new FakeUsuarioSessaoCollection() + const useCase = new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ usuarioId: 7 }) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value.refreshToken).toBe(TOKEN) + expect(result.value.session.usuarioId).toBe(7) + expect(result.value.session.refreshTokenHash).toBe(HASH) + expect(result.value.session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ) + expect(result.value.session.createdAt).toEqual(NOW) + expect(result.value.session.lastUsedAt).toEqual(NOW) + expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) + expect(collection.rows.get(result.value.session.id)?.refreshTokenHash).toBe(HASH) + }) + + test('propagates refresh generate failure', async () => { + const error = new RefreshTokenError({ message: 'generate failed' }) + const useCase = new CriaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: { + generate: () => Either.left(error), + hash: () => Either.right(HASH) + }, + now: () => NOW + }) + + const result = await useCase.execute({ usuarioId: 1 }) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts b/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts new file mode 100644 index 00000000..6533ac5a --- /dev/null +++ b/test/unit/domain/usuarioSessao/FakeUsuarioSessaoCollection.ts @@ -0,0 +1,69 @@ +import { type Attributes, UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { + type RotationUpdate, + type UsuarioSessaoCollection +} from '@/domain/usuarioSessao/UsuarioSessaoCollection' +import { Either } from '@/library/either/Either' + +export class FakeUsuarioSessaoCollection implements UsuarioSessaoCollection { + readonly rows = new Map() + + create(attributes: Attributes): Promise> { + const stored = { ...attributes } + this.rows.set(stored.id, stored) + return Promise.resolve(Either.right({ ...stored })) + } + + findById(id: string): Promise> { + const row = this.rows.get(id) + return Promise.resolve(Either.right(row ? { ...row } : null)) + } + + findByRefreshTokenHash(hash: string): Promise> { + const row = [...this.rows.values()].find(session => session.refreshTokenHash === hash) + return Promise.resolve(Either.right(row ? { ...row } : null)) + } + + updateRotation(id: string, update: RotationUpdate): Promise> { + const current = this.rows.get(id) + if (!current) { + return Promise.resolve(Either.right(null)) + } + + const next: Attributes = { + ...current, + refreshTokenHash: update.refreshTokenHash, + lastUsedAt: update.lastUsedAt, + expiresAt: update.expiresAt + } + this.rows.set(id, next) + return Promise.resolve(Either.right({ ...next })) + } + + deleteById(id: string): Promise> { + this.rows.delete(id) + return Promise.resolve(Either.right(undefined)) + } + + deleteByUsuarioId(usuarioId: number): Promise> { + for (const [id, session] of this.rows) { + if (session.usuarioId === usuarioId) { + this.rows.delete(id) + } + } + return Promise.resolve(Either.right(undefined)) + } +} + +export function makeSession(overrides?: Partial): Attributes { + const createdAt = overrides?.createdAt ?? new Date('2026-01-01T00:00:00.000Z') + return { + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 10, + refreshTokenHash: 'a'.repeat(64), + createdAt, + lastUsedAt: createdAt, + expiresAt: UsuarioSessao.refreshExpiresAt(createdAt), + ...overrides + } +} diff --git a/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts b/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts new file mode 100644 index 00000000..26de48d8 --- /dev/null +++ b/test/unit/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase.test.ts @@ -0,0 +1,100 @@ +import { + describe, expect, test +} from 'vitest' + +import { UserSessionNotFoundError } from '@/domain/usuarioSessao/error/UserSessionNotFoundError' +import { RotacionaUsuarioSessaoUseCase } from '@/domain/usuarioSessao/RotacionaUsuarioSessaoUseCase' +import { UsuarioSessao } from '@/domain/usuarioSessao/UsuarioSessao' +import { type RefreshToken } from '@/library/auth/RefreshToken' +import { Either } from '@/library/either/Either' + +import { + FakeUsuarioSessaoCollection, + makeSession +} from './FakeUsuarioSessaoCollection' + +const CURRENT_TOKEN = 'current-refresh' +const CURRENT_HASH = 'c'.repeat(64) +const NEW_TOKEN = 'new-refresh' +const NEW_HASH = 'd'.repeat(64) +const NOW = new Date('2026-09-27T12:00:00.000Z') + +function makeRefreshToken(): RefreshToken { + return { + generate: () => Either.right({ + token: NEW_TOKEN, + hash: NEW_HASH + }), + hash: token => { + if (token === CURRENT_TOKEN) { + return Either.right(CURRENT_HASH) + } + return Either.right('unknown'.padEnd(64, '0')) + } + } +} + +describe('RotacionaUsuarioSessaoUseCase', () => { + test('updates hash, lastUsedAt, and expiresAt on the same id', async () => { + const collection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + refreshTokenHash: CURRENT_HASH, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2026-10-01T00:00:00.000Z') + }) + await collection.create(existing) + + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value.refreshToken).toBe(NEW_TOKEN) + expect(result.value.session.id).toBe(existing.id) + expect(result.value.session.refreshTokenHash).toBe(NEW_HASH) + expect(result.value.session.lastUsedAt).toEqual(NOW) + expect(result.value.session.expiresAt).toEqual(UsuarioSessao.refreshExpiresAt(NOW)) + expect(collection.rows.get(existing.id)?.refreshTokenHash).toBe(NEW_HASH) + }) + + test('returns not found when the refresh hash is unknown', async () => { + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: new FakeUsuarioSessaoCollection(), + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: 'missing' }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + }) + + test('deletes an expired session and returns not found', async () => { + const collection = new FakeUsuarioSessaoCollection() + const existing = makeSession({ + refreshTokenHash: CURRENT_HASH, + expiresAt: new Date('2026-09-27T11:00:00.000Z') + }) + await collection.create(existing) + + const useCase = new RotacionaUsuarioSessaoUseCase({ + usuarioSessaoCollection: collection, + refreshToken: makeRefreshToken(), + now: () => NOW + }) + + const result = await useCase.execute({ refreshToken: CURRENT_TOKEN }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(UserSessionNotFoundError) + expect(collection.rows.has(existing.id)).toBe(false) + }) +}) diff --git a/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts b/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts new file mode 100644 index 00000000..3e6a728e --- /dev/null +++ b/test/unit/infrastructure/UsuarioSessaoCollectionKnexAdapter.test.ts @@ -0,0 +1,179 @@ +import type { Knex } from 'knex' +import { + describe, expect, test, vi +} from 'vitest' + +import type { Attributes } from '@/domain/usuarioSessao/UsuarioSessao' +import { UsuarioSessaoCollectionKnexAdapter } from '@/infrastructure/UsuarioSessaoCollectionKnexAdapter' + +const COLUMNS = [ + 'id', + 'usuario_id', + 'refresh_token_hash', + 'created_at', + 'last_used_at', + 'expires_at' +] + +const attributes: Attributes = { + id: '11111111-1111-4111-8111-111111111111', + usuarioId: 4, + refreshTokenHash: 'a'.repeat(64), + createdAt: new Date('2026-09-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-09-01T00:00:00.000Z'), + expiresAt: new Date('2026-10-01T00:00:00.000Z') +} + +const row = { + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt +} + +function stubKnex(promise: Promise): Knex & { builder: Record } { + const builder = {} as Record + builder.insert = vi.fn().mockImplementation(() => builder) + builder.returning = vi.fn().mockImplementation(() => builder) + builder.select = vi.fn().mockImplementation(() => builder) + builder.where = vi.fn().mockImplementation(() => builder) + builder.first = vi.fn().mockImplementation(() => builder) + builder.update = vi.fn().mockImplementation(() => builder) + builder.delete = vi.fn().mockImplementation(() => builder) + builder.then = ( + onResolved: (v: TResult) => unknown, + onRejected?: (e: unknown) => unknown + ): Promise => promise.then(onResolved, onRejected) + + const knex = vi.fn(() => builder) as unknown as Knex & { + builder: Record + } + knex.builder = builder + return knex +} + +describe('UsuarioSessaoCollectionKnexAdapter', () => { + test('create inserts snake_case columns and maps the row', async () => { + const knex = stubKnex(Promise.resolve([row])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.create(attributes) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(knex).toHaveBeenCalledWith('usuarios_sessoes') + expect(knex.builder.insert).toHaveBeenCalledWith({ + id: attributes.id, + usuario_id: attributes.usuarioId, + refresh_token_hash: attributes.refreshTokenHash, + created_at: attributes.createdAt, + last_used_at: attributes.lastUsedAt, + expires_at: attributes.expiresAt + }) + expect(knex.builder.returning).toHaveBeenCalledWith(COLUMNS) + }) + + test('findById maps a row and returns null when missing', async () => { + const knex = stubKnex(Promise.resolve(row)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const found = await adapter.findById(attributes.id) + + expect(found.right()).toBe(true) + expect(found.value).toEqual(attributes) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.select).toHaveBeenCalledWith(COLUMNS) + + const missingKnex = stubKnex(Promise.resolve(undefined)) + const missing = await new UsuarioSessaoCollectionKnexAdapter({ knex: missingKnex }) + .findById(attributes.id) + expect(missing.right()).toBe(true) + expect(missing.value).toBeNull() + }) + + test('findByRefreshTokenHash looks up by hash', async () => { + const knex = stubKnex(Promise.resolve(row)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.findByRefreshTokenHash(attributes.refreshTokenHash) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(knex.builder.where).toHaveBeenCalledWith({ + refresh_token_hash: attributes.refreshTokenHash + }) + }) + + test('updateRotation updates only rotation columns', async () => { + const updated = { + ...row, + refresh_token_hash: 'b'.repeat(64), + last_used_at: new Date('2026-09-27T00:00:00.000Z'), + expires_at: new Date('2026-10-27T00:00:00.000Z') + } + const knex = stubKnex(Promise.resolve([updated])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.updateRotation(attributes.id, { + refreshTokenHash: updated.refresh_token_hash, + lastUsedAt: updated.last_used_at, + expiresAt: updated.expires_at + }) + + expect(result.right()).toBe(true) + expect(result.value).toEqual({ + id: attributes.id, + usuarioId: attributes.usuarioId, + refreshTokenHash: updated.refresh_token_hash, + createdAt: attributes.createdAt, + lastUsedAt: updated.last_used_at, + expiresAt: updated.expires_at + }) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.update).toHaveBeenCalledWith({ + refresh_token_hash: updated.refresh_token_hash, + last_used_at: updated.last_used_at, + expires_at: updated.expires_at + }) + }) + + test('updateRotation returns null when no row is updated', async () => { + const knex = stubKnex(Promise.resolve([])) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.updateRotation(attributes.id, { + refreshTokenHash: 'b'.repeat(64), + lastUsedAt: attributes.lastUsedAt, + expiresAt: attributes.expiresAt + }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + }) + + test('deleteById and deleteByUsuarioId succeed', async () => { + const knex = stubKnex(Promise.resolve(1)) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const byId = await adapter.deleteById(attributes.id) + expect(byId.right()).toBe(true) + expect(knex.builder.where).toHaveBeenCalledWith({ id: attributes.id }) + expect(knex.builder.delete).toHaveBeenCalled() + + const byUser = await adapter.deleteByUsuarioId(4) + expect(byUser.right()).toBe(true) + expect(knex.builder.where).toHaveBeenCalledWith({ usuario_id: 4 }) + }) + + test('returns Either.left on failure', async () => { + const knex = stubKnex(Promise.reject(new Error('DB down'))) + const adapter = new UsuarioSessaoCollectionKnexAdapter({ knex }) + + const result = await adapter.create(attributes) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(Error) + }) +}) diff --git a/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts b/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts new file mode 100644 index 00000000..554921f1 --- /dev/null +++ b/test/unit/infrastructure/auth/CryptoRefreshToken.test.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { + describe, + expect, + test +} from 'vitest' + +import { CryptoRefreshToken } from '@/infrastructure/auth/CryptoRefreshToken' + +describe('CryptoRefreshToken', () => { + const refreshToken = new CryptoRefreshToken() + + test('generated hash matches hash()', () => { + const generated = refreshToken.generate() + + expect(generated.right()).toBe(true) + if (!generated.right()) return + + const hashed = refreshToken.hash(generated.value.token) + expect(hashed.right()).toBe(true) + if (!hashed.right()) return + + expect(generated.value.hash).toBe(hashed.value) + expect(Buffer.from(generated.value.token, 'base64url')).toHaveLength(32) + }) + + test('two generated tokens differ', () => { + const first = refreshToken.generate() + const second = refreshToken.generate() + + expect(first.right()).toBe(true) + expect(second.right()).toBe(true) + if (!first.right() || !second.right()) return + + expect(first.value.token).not.toBe(second.value.token) + expect(first.value.hash).not.toBe(second.value.hash) + }) + + test('hash is hex SHA-256', () => { + const token = 'opaque-refresh-token' + const hashed = refreshToken.hash(token) + + expect(hashed.right()).toBe(true) + if (!hashed.right()) return + + expect(hashed.value).toHaveLength(64) + expect(hashed.value).toBe(createHash('sha256').update(token, 'utf8').digest('hex')) + expect(hashed.value).toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/test/unit/infrastructure/auth/JwtAccessToken.test.ts b/test/unit/infrastructure/auth/JwtAccessToken.test.ts new file mode 100644 index 00000000..5f4a0652 --- /dev/null +++ b/test/unit/infrastructure/auth/JwtAccessToken.test.ts @@ -0,0 +1,110 @@ +import jwt from 'jsonwebtoken' +import { + afterEach, + describe, + expect, + test, + vi +} from 'vitest' + +import { JwtAccessToken } from '@/infrastructure/auth/JwtAccessToken' +import { AccessTokenExpiredError } from '@/library/auth/error/AccessTokenExpiredError' +import { AccessTokenInvalidError } from '@/library/auth/error/AccessTokenInvalidError' + +const SECRET = 'test-secret-for-access-tokens' + +describe('JwtAccessToken', () => { + const accessToken = new JwtAccessToken({ secret: SECRET }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('round-trips access claims', () => { + const signed = accessToken.sign({ + sub: 7, + sid: 'session-1', + role: 2 + }) + + expect(signed.right()).toBe(true) + if (!signed.right()) return + + const result = accessToken.verify(signed.value) + + expect(result.right()).toBe(true) + if (!result.right()) return + + expect(result.value).toMatchObject({ + sub: 7, + sid: 'session-1', + typ: 'access', + role: 2 + }) + expect(typeof result.value.iat).toBe('number') + expect(typeof result.value.exp).toBe('number') + }) + + test('rejects an expired access token', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + const signed = accessToken.sign({ + sub: 1, + sid: 'session-expired', + role: 1 + }) + expect(signed.right()).toBe(true) + if (!signed.right()) return + + vi.setSystemTime(new Date('2026-01-01T00:16:00.000Z')) + + const result = accessToken.verify(signed.value) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenExpiredError) + }) + + test('rejects a garbage token', () => { + const result = accessToken.verify('not-a-jwt') + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) + + test('rejects a token whose typ is not access', () => { + const token = jwt.sign( + { + sub: '1', + sid: 'session-2', + typ: 'refresh', + role: 1 + }, + SECRET, + { expiresIn: '15m' } + ) + + const result = accessToken.verify(token) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) + + test('rejects a token signed with another algorithm', () => { + const token = jwt.sign( + { + sub: '1', + sid: 'session-3', + typ: 'access', + role: 1 + }, + SECRET, + { + algorithm: 'HS384', + expiresIn: '15m' + } + ) + + const result = accessToken.verify(token) + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(AccessTokenInvalidError) + }) +}) diff --git a/test/unit/library/auth/Manager.test.ts b/test/unit/library/auth/Manager.test.ts new file mode 100644 index 00000000..2e330f1e --- /dev/null +++ b/test/unit/library/auth/Manager.test.ts @@ -0,0 +1,80 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { Manager, type Rule } from '@/library/auth/Manager' + +type Resource = 'Cidade' | 'Pais' +type Action = 'read' | 'update' | 'create' + +describe('Manager', () => { + test('allows a type-level action that has a rule', () => { + const manager = new Manager({ + rules: [{ action: 'read', resource: 'Cidade' }] + }) + + expect(manager.can('read', 'Cidade')).toBe(true) + expect(manager.can('update', 'Cidade')).toBe(false) + expect(manager.can('read', 'Pais')).toBe(false) + }) + + test('type-level can is true when the only matching rule has conditions', () => { + const rules: Rule[] = [ + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + const manager = new Manager({ rules }) + + expect(manager.can('update', 'Cidade')).toBe(true) + }) + + test('record-level can matches conditions', () => { + const manager = new Manager({ + rules: [ + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + }) + + expect(manager.can('update', 'Cidade', { id: 10 })).toBe(true) + expect(manager.can('update', 'Cidade', { id: 11 })).toBe(false) + }) + + test('canAny and canAll honor empty lists and optional records', () => { + const manager = new Manager({ + rules: [ + { action: 'read', resource: 'Cidade' }, + { + action: 'update', + resource: 'Cidade', + conditions: { id: 10 } + } + ] + }) + + expect(manager.canAny([], 'Cidade')).toBe(false) + expect(manager.canAll([], 'Cidade')).toBe(false) + expect(manager.canAny(['read', 'create'], 'Cidade')).toBe(true) + expect(manager.canAll(['read', 'update'], 'Cidade')).toBe(true) + expect(manager.canAll(['read', 'create'], 'Cidade')).toBe(false) + expect(manager.canAny(['update'], 'Cidade', { id: 10 })).toBe(true) + expect(manager.canAny(['update'], 'Cidade', { id: 11 })).toBe(false) + expect(manager.canAll(['update'], 'Cidade', { id: 10 })).toBe(true) + expect(manager.canAll(['update'], 'Cidade', { id: 11 })).toBe(false) + }) + + test('exposes the input rules', () => { + const rules: Rule[] = [{ action: 'read', resource: 'Pais' }] + const manager = new Manager({ rules }) + + expect(manager.rules).toBe(rules) + }) +}) diff --git a/test/unit/library/auth/create-rules.test.ts b/test/unit/library/auth/create-rules.test.ts new file mode 100644 index 00000000..7c413b22 --- /dev/null +++ b/test/unit/library/auth/create-rules.test.ts @@ -0,0 +1,22 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { createRules } from '@/library/auth/createRules' + +describe('createRules', () => { + test('returns no rules for curador, operador, identificador, and unknown tipo', () => { + const users = [ + { id: 1, tipo_usuario_id: 1 }, + { id: 2, tipo_usuario_id: 2 }, + { id: 3, tipo_usuario_id: 3 }, + { id: 99, tipo_usuario_id: 99 } + ] + + for (const user of users) { + expect(createRules(user)).toEqual([]) + } + }) +}) diff --git a/yarn.lock b/yarn.lock index c60eea79..2cfb2b34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,6 +73,13 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@casl/ability@6.8.0": + version "6.8.0" + resolved "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz#db878b28339e275b6cb019a887b882b76a8abe3e" + integrity sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA== + dependencies: + "@ucast/mongo2js" "^1.3.0" + "@esbuild/aix-ppc64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" @@ -776,6 +783,14 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/jsonwebtoken@9.0.10": + version "9.0.10" + resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz#a7932a47177dcd4283b6146f3bd5c26d82647f09" + integrity sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA== + dependencies: + "@types/ms" "*" + "@types/node" "*" + "@types/methods@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" @@ -793,6 +808,11 @@ dependencies: "@types/node" "*" +"@types/ms@*": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@*": version "25.9.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9" @@ -996,6 +1016,34 @@ "@typescript-eslint/types" "8.46.3" eslint-visitor-keys "^4.2.1" +"@ucast/core@1.10.2", "@ucast/core@^1.4.1": + version "1.10.2" + resolved "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz#30b6b893479823265368e528b61b042f752f2c92" + integrity sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== + +"@ucast/js@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@ucast/js/-/js-3.1.0.tgz#2f965afd1fcf0b9c5ba720f79a21fbed3be6d59f" + integrity sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q== + dependencies: + "@ucast/core" "1.10.2" + +"@ucast/mongo2js@^1.3.0": + version "1.4.1" + resolved "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.1.tgz#2d8193212e2a79375d083e706cf5012f5d9cad7e" + integrity sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA== + dependencies: + "@ucast/core" "1.10.2" + "@ucast/js" "3.1.0" + "@ucast/mongo" "2.4.3" + +"@ucast/mongo@2.4.3": + version "2.4.3" + resolved "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz#92b1dd7c0ab06a907f2ab1422aa3027518ccc05e" + integrity sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== + dependencies: + "@ucast/core" "^1.4.1" + "@vitest/coverage-v8@4.0.7": version "4.0.7" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.7.tgz#0449407f97b10c03230a29778064a85698821bc6"