From 30ef9dae74518ac029e9eacbf8852d33a4696cc3 Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:38:58 -0300 Subject: [PATCH 1/8] docs: start the decisions document with the code structure block docs/decisoes-de-arquitetura.md will replace the 33 ADRs with about fifteen decisions, each answering the same six questions, in Portuguese because it is also the owner's study material. This first block covers the platform, the layers, the domain model, endpoints, handlers, validation, errors, mapping and the API reference. The owner reviewed three of them and kept Minimal APIs, the validation decorator and the handler interfaces. The comment convention agreed in the same review goes into the coding conventions. --- docs/agents/guides/coding-conventions.md | 1 + docs/decisoes-de-arquitetura.md | 233 +++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 docs/decisoes-de-arquitetura.md diff --git a/docs/agents/guides/coding-conventions.md b/docs/agents/guides/coding-conventions.md index 5aa5ed2..9be60f9 100644 --- a/docs/agents/guides/coding-conventions.md +++ b/docs/agents/guides/coding-conventions.md @@ -35,6 +35,7 @@ Translate business terms exactly as in the [domain glossary](../../product/gloss - Prefer small methods with early returns over nested conditionals. - Records for value objects and request/response models; `sealed` classes by default. - Comments explain *why*, not *what*. No commented-out code. +- A comment stands on its own: one sentence the reader understands without opening anything else. It may cite a business rule ID (`AUTH-08`), which is stable; it never cites a decision number, an issue, a pull request or a plan code. The one exception is the ID of a test (`// #10-T03`) on the line above a test of an issue. - Nullable reference types are on: do not silence warnings with `!` unless the reason is obvious or commented. ## Packages diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md new file mode 100644 index 0000000..b84d3ab --- /dev/null +++ b/docs/decisoes-de-arquitetura.md @@ -0,0 +1,233 @@ +# Decisões de arquitetura + +Este documento explica como o back-end do Control Service é construído e por quê. Ele está em português porque também é o material de estudo do dono do projeto: a meta é conseguir explicar cada decisão com as próprias palavras. + +Cada decisão responde às mesmas seis perguntas: + +1. **O que é.** +2. **Que problema resolve.** +3. **O que acontece sem isso.** +4. **Quanto custa.** +5. **Quais são as alternativas mais simples.** +6. **Por que escolhemos assim.** + +No fim de cada uma há dois atalhos: **onde ver no código** e **em uma frase**, que é a resposta curta para uma entrevista. + +As regras de negócio não estão aqui: elas ficam em [product/](product/). Este documento trata só de *como* o sistema é construído. + +## Bloco A — Estrutura e fluxo do código + +### O caminho de uma requisição + +Antes das decisões, o mapa. Este é o caminho real de "trocar a senha" (`POST /api/v1/auth/change-password`); todas as outras operações seguem o mesmo desenho. + +``` +Requisição + │ + ▼ +Endpoint (API) lê o token, monta o command e chama o handler + │ + ▼ +Validação (Application) confere o formato dos dados; se estiver errado, responde aqui mesmo + │ + ▼ +Handler (Application) executa o caso de uso: busca, decide, salva + │ │ + │ ▼ + │ Domínio as regras de negócio (agregados e Value Objects) + ▼ +Repositório (Infrastructure) fala com o banco, atrás de uma interface + │ + ▼ +Result ──► Endpoint ──► resposta 200, ou um JSON de erro padronizado +``` + +1. A requisição chega com o token, e o ASP.NET confere se ele é válido. +2. O endpoint `ChangePassword` lê do token o id de quem está logado, monta o `ChangePasswordCommand` e chama o handler. +3. Antes do handler roda o `ChangePasswordValidator`: senha com o tamanho mínimo, confirmação igual à senha. Se algo estiver errado, a resposta sai daqui, com a mensagem de cada campo, e o handler nem é executado. +4. O `ChangePasswordHandler` busca o usuário, confere as regras que dependem do banco, chama o domínio, salva e abre uma sessão nova. +5. O handler devolve um `Result`. O endpoint o traduz: sucesso vira `200`; falha vira um JSON de erro com o status certo. + +Cada peça desse caminho é uma das decisões abaixo. + +### 1. .NET 10 e C# 14 + +1. **O que é.** Todos os projetos usam o .NET 10. A versão do SDK fica fixada no `global.json`, para que a máquina de desenvolvimento e o CI compilem com a mesma versão. +2. **Que problema resolve.** As versões pares do .NET têm suporte longo, de três anos. O .NET 10 saiu em novembro de 2025 e tem suporte até novembro de 2028. O .NET 8 e o .NET 9 perdem o suporte em 10 de novembro de 2026. +3. **O que acontece sem isso.** Um projeto começado no .NET 8 ficaria sem suporte em pouco tempo e teria de migrar no meio do desenvolvimento. +4. **Quanto custa.** Alguns pacotes demoram a suportar a versão nova. Antes de adicionar um pacote, é preciso conferir se ele funciona com `net10.0`. +5. **Alternativas mais simples.** O .NET 8, que ainda é o mais comum em sistemas existentes. +6. **Por que escolhemos assim.** Não há ganho em começar numa versão que está acabando. + +- **Onde ver no código:** [global.json](../backend/ControlService/global.json), [Directory.Build.props](../backend/ControlService/Directory.Build.props). +- **Em uma frase:** escolhi a versão de suporte longo mais recente, para não ter de migrar durante a vida do projeto. + +### 2. Quatro projetos em camadas, com pastas por funcionalidade + +1. **O que é.** O código de produção fica em quatro projetos, e cada um só pode referenciar os que estão "para dentro": + + ``` + API ──────► Application ──────► Domain + ▲ + Infrastructure ──┘ + ``` + + | Projeto | Contém | Pode referenciar | + |---|---|---| + | `Domain` | Agregados, Value Objects, regras de negócio | Nada: nem pacotes, nem EF Core, nem ASP.NET Core | + | `Application` | Casos de uso (handlers), validadores e as interfaces de que eles precisam | `Domain` | + | `Infrastructure` | EF Core, repositórios, Identity | `Application`, `Domain` | + | `API` | Endpoints, autenticação, montagem do sistema | `Application`; `Infrastructure` só para registrar os serviços | + + Dentro de cada projeto, as pastas são por funcionalidade (`Users`, `Auth`, `PermissionProfiles`), não por tipo técnico. +2. **Que problema resolve.** As regras de negócio não dependem de banco nem de web. Por isso os testes de domínio rodam em cerca de um segundo, sem subir nada. E quem procura "onde fica o login" acha a pasta `Auth` em cada projeto. +3. **O que acontece sem isso.** Num projeto único com pastas, tudo funciona, mas nada impede uma classe de domínio de usar o EF Core. A separação passa a depender só de disciplina. +4. **Quanto custa.** Uma funcionalidade nova toca em vários projetos, e cruzar uma camada exige uma interface (decisão 5 mostra quais). +5. **Alternativas mais simples.** Um projeto só, com pastas. Ou um projeto por área do menu (Gerenciamento, Comercial, Financeiro), que foi a primeira tentativa: ele separa áreas de negócio, que dependem umas das outras o tempo todo, e não impede o domínio de usar o banco. +6. **Por que escolhemos assim.** É o desenho em camadas que o dono já conhece, é muito pedido em vagas .NET, e a regra de dependência é conferida por testes: se uma camada referenciar a errada, o build falha. + +- **Onde ver no código:** [LayerDependencyTests.cs](../backend/ControlService/tests/ControlService.ArchitectureTests/LayerDependencyTests.cs). +- **Em uma frase:** as regras de negócio ficam num projeto que não conhece banco nem web, e um teste quebra o build se alguém violar isso. + +### 3. Domínio com agregados e Value Objects + +1. **O que é.** Três tipos de classe no projeto `Domain`: + - **Agregado:** uma entidade que guarda os próprios dados e só os altera por métodos com nome de negócio. `User` e `PermissionProfile` são agregados. Escreve-se `user.Deactivate(by, now)`, nunca `user.Status = ...`; não existe `set` público. Um agregado referencia outro só pelo id. + - **Value Object:** um tipo pequeno e imutável que só existe se for válido. `Cpf.Create("111.111.111-11")` devolve um erro, e não um CPF inválido. Os outros são `Login`, `EmailAddress`, `PhoneNumber`, `Cep`, `BloodType`, `ScreenKey` e `AccessLevel`. + - **Serviço de domínio:** uma regra que envolve mais de um agregado. `EffectiveAccess` calcula o nível de uma pessoa a partir de todos os perfis dela. +2. **Que problema resolve.** Cada regra fica em um lugar só e não dá para contorná-la. "O Admin não pode ser desativado" está dentro de `User.Deactivate`, então vale para qualquer tela ou endpoint que chegue ali. +3. **O que acontece sem isso.** Com entidades só de `get` e `set`, a regra vai para cada service ou handler que mexe na entidade. Ela acaba repetida, e um dia alguém esquece uma das cópias. +4. **Quanto custa.** Mais classes, e o EF Core precisa de um conversor para cada Value Object. +5. **Alternativas mais simples.** Entidades anêmicas (só dados) com a validação nos handlers. +6. **Por que escolhemos assim.** As regras deste sistema são do negócio, não de uma tela, e ficam testáveis sem banco. **Regra de proporção:** um campo só vira Value Object quando tem regra de formato ou validação; um método de comportamento só existe onde há regra ou mudança de estado. Um cadastro simples, com nome e descrição, é uma entidade pequena. + +- **Onde ver no código:** [User.cs](../backend/ControlService/src/ControlService.Domain/Users/User.cs), [Cpf.cs](../backend/ControlService/src/ControlService.Domain/Users/Cpf.cs), [EffectiveAccess.cs](../backend/ControlService/src/ControlService.Domain/Access/EffectiveAccess.cs), [AggregateTests.cs](../backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs). +- **Em uma frase:** a entidade protege as próprias regras, e um valor inválido nem chega a ser criado. + +### 4. Endpoints em Minimal APIs + +1. **O que é.** Cada endpoint é um método registrado com `api.MapGet("/me", Me)`, numa classe por funcionalidade (`AuthEndpoints`). Não há classes Controller. + + ```csharp + // Aqui + api.MapGet("/me", Me); + private static async Task<...> Me(ClaimsPrincipal principal, IQueryHandler handler, ...) + + // O mesmo com um Controller + [HttpGet("me")] + public async Task Me(...) // o handler viria pelo construtor + ``` + +2. **Que problema resolve.** Menos cerimônia: sem atributos nem classe base. Uma regra que vale para um grupo de rotas fica em um lugar só; é assim que a troca obrigatória da senha bloqueia tudo o que está em `/api/v1`. +3. **O que acontece sem isso.** Com Controllers, o sistema funcionaria igual. Não se perde funcionalidade. +4. **Quanto custa.** As dependências chegam como parâmetros do método, e não pelo construtor, o que estranha no começo. É preciso uma convenção (um `Map...Endpoints` por funcionalidade) para o `Program.cs` não crescer. +5. **Alternativas mais simples.** Controllers, o estilo mais comum em sistemas existentes. +6. **Por que escolhemos assim.** É o estilo padrão dos modelos atuais do ASP.NET Core, já está construído e testado, e a diferença para um Controller é pequena. Reavaliada em 2026-10-05: mantida. + +- **Onde ver no código:** [AuthEndpoints.cs](../backend/ControlService/src/ControlService.API/Auth/AuthEndpoints.cs), [ApiV1Group.cs](../backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs). +- **Em uma frase:** um endpoint aqui é um método de controller sem a classe em volta; sei fazer dos dois jeitos. + +### 5. Um handler por caso de uso, sem MediatR + +1. **O que é.** Cada operação do sistema é uma classe com um método só, `Handle`. É um Service de um método: + + | Num Service tradicional | Aqui | + |---|---| + | `UserService`, com um método por operação | Uma classe por operação: `SignInHandler`, `ChangePasswordHandler` | + | O DTO de entrada | O *command* (altera algo) ou a *query* (só consulta) | + + Os handlers implementam `ICommandHandler` ou `IQueryHandler`. O endpoint recebe o handler por injeção de dependência e o chama direto. +2. **Que problema resolve.** Cada classe fica pequena e recebe só as dependências daquela operação. Separar consulta de alteração permite que uma consulta devolva os dados direto, sem passar pelas regras de escrita. +3. **O que acontece sem isso.** Um `UserService` com oito métodos e todas as dependências de todos eles. +4. **Quanto custa.** Mais arquivos (command, handler e validador por operação), e cada handler é registrado à mão. +5. **Alternativas mais simples.** Services com vários métodos. Ou a biblioteca MediatR, em que o endpoint chama `mediator.Send(command)` e ela encontra o handler: desde julho de 2025 ela tem edição comercial (a versão 12 continua aberta, mas sem atualizações). +6. **Por que escolhemos assim.** O formato é o mesmo do MediatR, então conhecer um ajuda a entender o outro, sem depender da biblioteca. E "ir para a implementação" a partir do endpoint leva direto ao handler. + +**As interfaces do projeto são de dois tipos.** Vale saber separar: + +| Interface | Definida em | Implementada em | Para quê | +|---|---|---|---| +| `IUserRepository`, `IPermissionProfileRepository`, `IUnitOfWork`, `ICredentialStore`, `ISessionStore` | `Application` | `Infrastructure` | **Inversão de dependência:** o handler precisa do banco, mas a `Application` não pode depender do EF Core | +| `IAccessTokenIssuer`, `ICurrentUser` | `Application` | `API` | **Inversão de dependência**, pelo mesmo motivo | +| `ICommandHandler<,>`, `IQueryHandler<,>` | `Application` | `Application` (os próprios handlers) | **Formato comum:** todo handler tem a mesma "cara", o que permite embrulhar qualquer um deles (decisão 6) | + +As sete primeiras também são o que permite testar um handler com um repositório falso, em memória. As duas últimas não invertem dependência nenhuma: a API já pode enxergar a `Application`. Foram reavaliadas em 2026-10-05 e mantidas. + +- **Onde ver no código:** [ICommandHandler.cs](../backend/ControlService/src/ControlService.Application/Common/ICommandHandler.cs), [SignInHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/SignIn/SignInHandler.cs), e o registro em [AuthServiceCollectionExtensions.cs](../backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs). +- **Em uma frase:** cada caso de uso é uma classe pequena; é o formato do MediatR, sem a biblioteca. + +### 6. Validação com FluentValidation, aplicada por um decorator + +1. **O que é.** Três peças: + - **Validator:** uma classe que confere só o formato dos dados de um command, sem acessar o banco. Faz o papel de `[Required]` e `[MinLength]`, mas em uma classe separada, com as mensagens em português. + - **Decorator:** uma classe que implementa a mesma interface de outra e a embrulha. `ValidatingCommandHandler` recebe o handler de verdade, roda o validador e só chama o handler se os dados estiverem certos. Quem usa não percebe a diferença. + - **Um decorator só para todos os handlers.** Para validar um handler novo, escreve-se um *Validator* novo; o decorator é sempre o mesmo. + + A validação tem três lugares, e cada regra vai para um só: + + | Tipo de regra | Onde fica | Exemplo | + |---|---|---| + | Formato da entrada | Validator | Senha com pelo menos 8 caracteres; confirmação igual à senha | + | Regra que precisa do banco | Handler | Já existe um usuário com esse login | + | Regra de negócio | Domínio | Ninguém desativa a própria conta | + +2. **Que problema resolve.** O back-end não pode confiar no navegador. Com o decorator, o handler só contém o caso de uso: quando ele roda, os dados já estão no formato certo. Os erros voltam por campo (`address.cep`), para a tela mostrar cada mensagem no lugar. +3. **O que acontece sem isso.** O handler chamaria o validador na primeira linha. Funciona, mas dá para esquecer. +4. **Quanto custa.** Dois conceitos a mais para explicar (interface genérica e decorator), e o registro de um handler validado é feito à mão, em quatro linhas. +5. **Alternativas mais simples.** Data Annotations, que são limitadas para regras como "se preenchido, deve ser válido". Ou chamar o validador dentro do handler, sem decorator. +6. **Por que escolhemos assim.** A validação é automática e o handler fica só com a regra de negócio. Reavaliada em 2026-10-05, com as alternativas "validar dentro do handler" e "handlers sem interface": o dono escolheu manter. + +- **Onde ver no código:** [ValidatingCommandHandler.cs](../backend/ControlService/src/ControlService.Application/Common/ValidatingCommandHandler.cs), [ChangePasswordValidator.cs](../backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordValidator.cs). +- **Em uma frase:** um "porteiro" confere os dados antes do handler; se estiverem errados, o handler nem roda. + +### 7. Erros esperados como `Result`; erro HTTP em Problem Details + +1. **O que é.** Uma falha prevista (login em uso, perfil ainda atribuído, senha errada) não é uma exceção: é um valor de retorno. Os handlers e os métodos do domínio devolvem `Result` ou `Result`, que carregam o valor ou um `Error` com um `code` estável e uma mensagem em português. + + ```csharp + return Result.Failure(AuthErrors.InvalidCredentials); + ``` + + Na API, `error.ToProblem()` transforma o erro em Problem Details, o formato padrão de erro HTTP (RFC 9457), usando uma tabela única de `code` → status. +2. **Que problema resolve.** A assinatura do método mostra que ele pode falhar, e o front-end recebe sempre o mesmo formato de erro, com um `code` para decidir o que fazer. +3. **O que acontece sem isso.** Com exceção para tudo, o caminho de erro fica escondido em blocos `catch`, mais difícil de acompanhar e de testar. +4. **Quanto custa.** Um `if (result.IsFailure)` depois de cada chamada, e uma linha na tabela para cada código novo. Um código sem linha na tabela é tratado como bug: vira o erro 500 genérico. +5. **Alternativas mais simples.** Lançar exceções e tratá-las num middleware. Ou uma biblioteca pronta de `Result`. +6. **Por que escolhemos assim.** O tipo `Result` é pequeno e feito em casa, sem dependência. Exceção fica só para o inesperado (banco fora do ar, bug), que responde 500 com uma mensagem genérica; o detalhe vai só para o log. + +- **Onde ver no código:** [Result.cs](../backend/ControlService/src/ControlService.Domain/Common/Result.cs), [Error.cs](../backend/ControlService/src/ControlService.Domain/Common/Error.cs), [ErrorResults.cs](../backend/ControlService/src/ControlService.API/Common/ErrorResults.cs), [ErrorStatusCodes.cs](../backend/ControlService/src/ControlService.API/Common/ErrorStatusCodes.cs). Os códigos estão em [api/conventions.md](api/conventions.md#error-codes). +- **Em uma frase:** erro esperado é retorno, não exceção, e a API responde sempre no mesmo formato. + +### 8. Mapeamento manual entre objetos + +1. **O que é.** A conversão entre agregado, command e resposta é escrita à mão, em métodos pequenos ao lado de cada modelo (`SessionResponse.From(...)`). +2. **Que problema resolve.** O que entra e o que sai da API fica explícito. Acrescentar um campo obriga a decidir se ele deve ser exposto. +3. **O que acontece sem isso.** Uma biblioteca copiaria os campos de mesmo nome sozinha, e um erro de configuração só apareceria com o sistema rodando. +4. **Quanto custa.** Algumas linhas repetitivas por modelo. +5. **Alternativas mais simples.** Esta já é a mais simples. A tradicional é o AutoMapper, que desde julho de 2025 tem edição comercial. +6. **Por que escolhemos assim.** Menos uma dependência, e o compilador avisa quando algo não bate. + +- **Onde ver no código:** [SessionResponse.cs](../backend/ControlService/src/ControlService.API/Auth/SessionResponse.cs). +- **Em uma frase:** prefiro algumas linhas explícitas a uma biblioteca que copia campos por conta própria. + +### 9. Documentação da API com OpenAPI e Scalar + +1. **O que é.** O documento OpenAPI (a descrição formal da API) é gerado a partir dos próprios endpoints, com o pacote da Microsoft. O Scalar mostra esse documento como uma página interativa em `/scalar`, no ambiente de desenvolvimento. +2. **Que problema resolve.** A documentação nunca fica diferente do código, e dá para testar cada rota pelo navegador. +3. **O que acontece sem isso.** A documentação seria escrita à mão e envelheceria. +4. **Quanto custa.** Quase nada: três linhas no `Program.cs`. +5. **Alternativas mais simples.** O Swashbuckle (Swagger), que deixou de vir nos modelos do ASP.NET Core a partir do .NET 9. +6. **Por que escolhemos assim.** É o caminho padrão atual. O plano de guardar um `openapi.json` no repositório e conferi-lo no CI foi descartado: a página `/scalar` já mostra o contrato. + +- **Onde ver no código:** [Program.cs](../backend/ControlService/src/ControlService.API/Program.cs). +- **Em uma frase:** a documentação da API é gerada do código, então não envelhece. + +## O que ficou de fora + +Avaliado e não adotado. Fica registrado para ninguém propor de novo sem um motivo novo. + +| Ideia | Por que não | +|---|---| +| Guardar um `openapi.json` no repositório e conferi-lo no CI | A página `/scalar` já mostra o contrato; seria um arquivo a mais para manter | +| MediatR e AutoMapper | Duas dependências, hoje com edição comercial, para algo que cabe em poucas linhas do próprio projeto | +| Controllers | Nenhum ganho de funcionalidade; exigiria reescrever o que já está testado | From 727222961d8893cda2ab1d5203aa571ba3236daf Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:15:19 -0300 Subject: [PATCH 2/8] docs: add the data block to the decisions document --- docs/decisoes-de-arquitetura.md | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index b84d3ab..4d8b7f2 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -222,12 +222,163 @@ As sete primeiras também são o que permite testar um handler com um repositór - **Onde ver no código:** [Program.cs](../backend/ControlService/src/ControlService.API/Program.cs). - **Em uma frase:** a documentação da API é gerada do código, então não envelhece. +## Bloco B — Dados + +### 10. PostgreSQL + +1. **O que é.** O banco de dados relacional do sistema, na versão 18. Em desenvolvimento e nos testes, ele roda num contêiner Docker. +2. **Que problema resolve.** Um ERP é feito de dados que dependem uns dos outros (cliente → serviço → cobrança). Um banco relacional garante essa ligação com chaves estrangeiras e transações. +3. **O que acontece sem isso.** Num banco de documentos, a integridade entre os cadastros ficaria por conta do código. +4. **Quanto custa.** O Docker precisa estar rodando, inclusive para os testes de integração. +5. **Alternativas mais simples.** O SQLite, que é um arquivo só, mas se comporta diferente em concorrência e em tipos de dado. Ou o SQL Server, o mais comum em vagas .NET, que tem licença paga em produção. +6. **Por que escolhemos assim.** É gratuito, e com o EF Core o código é quase o mesmo que seria para o SQL Server. Os testes de integração rodam contra o PostgreSQL de verdade, e não contra um substituto. + +- **Onde ver no código:** [DependencyInjection.cs](../backend/ControlService/src/ControlService.Infrastructure/DependencyInjection.cs). +- **Em uma frase:** dados de ERP são relacionais, e escolhi um banco relacional gratuito que os testes usam de verdade. + +### 11. EF Core com repositório e unidade de trabalho + +1. **O que é.** O handler não conhece o `DbContext`. Ele pede o agregado a um repositório, altera e chama a unidade de trabalho para salvar. Os dois ficam atrás de interfaces: + + ```csharp + // No handler (Application) + var user = await users.GetByIdAsync(command.UserId, cancellationToken); + user.CompleteFirstAccess(timeProvider.GetUtcNow()); + var saved = await unitOfWork.SaveChangesAsync(cancellationToken); + + // Na Infrastructure + public sealed class UserRepository(AppDbContext dbContext) : IUserRepository + { + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + dbContext.Users.SingleOrDefaultAsync(user => user.Id == id, cancellationToken); + } + ``` + + Três detalhes deste projeto: + - Cada repositório tem só os métodos que algum handler usa. Não há repositório genérico. + - Cada Value Object tem um conversor na configuração do EF Core: `HasConversion(login => login.Value, value => Login.Create(value).Value)`. + - As tabelas são criadas por *migrations*, arquivos que descrevem cada mudança do banco. Elas são aplicadas sozinhas só em desenvolvimento. +2. **Que problema resolve.** A `Application` não pode depender do EF Core (decisão 2), e o `DbContext` é do EF Core. A interface resolve isso. De quebra, um handler pode ser testado com um repositório falso, que guarda os dados numa lista. +3. **O que acontece sem isso.** O handler receberia o `AppDbContext`. A `Application` passaria a depender do EF Core, e todo teste de handler precisaria de um banco. +4. **Quanto custa.** Uma interface e uma classe por agregado, e uma migration a cada mudança de modelo. +5. **Alternativas mais simples.** Usar o `DbContext` direto no handler, como muitos projetos atuais fazem. Ou escrever o SQL à mão com o Dapper. +6. **Por que escolhemos assim.** É o desenho que o dono já domina e é coerente com a regra de camadas. **O sistema sempre grava no PostgreSQL:** o repositório em memória existe só no projeto de testes, e é a exceção. A maioria das operações é testada pelo endpoint, contra o banco de verdade. Reavaliada em 2026-10-05: mantida. + +- **Onde ver no código:** [IUserRepository.cs](../backend/ControlService/src/ControlService.Application/Users/IUserRepository.cs), [UserRepository.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/UserRepository.cs), [UnitOfWork.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/UnitOfWork.cs), [UserConfiguration.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/Configurations/UserConfiguration.cs), e o repositório falso em [InMemoryUserRepository.cs](../backend/ControlService/tests/ControlService.Application.Tests/Fakes/InMemoryUserRepository.cs). +- **Em uma frase:** o handler fala com o banco por uma interface, então a regra de negócio não depende do EF Core e pode ser testada sem banco. + +### 12. Ids UUID v7, gerados no domínio + +1. **O que é.** O id de cada registro é um `Guid` criado pelo próprio agregado, e não um número gerado pelo banco: + + ```csharp + public static User Create(Login login, EmailAddress email, string displayName, string fullName) => + new(Guid.CreateVersion7(), isSystem: false, UserStatus.Pending, login, email, displayName, fullName); + ``` + + A versão 7 do UUID começa pela data e hora, então os ids saem em ordem crescente. +2. **Que problema resolve.** O objeto já nasce com id, antes de ser salvo. O id não revela quantos registros existem, e ninguém adivinha o próximo trocando `/users/42` por `/users/43`. +3. **O que acontece sem isso.** Com `int` auto-incremento, o id só existe depois do `INSERT`. Com um Guid aleatório (versão 4), cada registro novo cai num ponto qualquer do índice do banco, que fica fragmentado e mais lento. +4. **Quanto custa.** 16 bytes em vez de 4, e ids ruins de ler e de ditar. +5. **Alternativas mais simples.** `int` gerado pelo banco. +6. **Por que escolhemos assim.** `Guid.CreateVersion7()` já vem no .NET, sem pacote, e junta a vantagem do Guid (id antes de salvar) com a do número (ordem crescente). + +- **Onde ver no código:** [User.cs](../backend/ControlService/src/ControlService.Domain/Users/User.cs), [SystemIds.cs](../backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs). +- **Em uma frase:** o id é um Guid ordenado por tempo, criado junto com o objeto, que não entrega a contagem de registros. + +### 13. Concorrência otimista + +1. **O que é.** Cada registro tem uma versão. Quem edita devolve a versão que leu; se o registro mudou nesse meio-tempo, o salvamento é recusado. No PostgreSQL a versão é a coluna de sistema `xmin`, que o próprio banco troca a cada `UPDATE`. No código, basta uma linha por agregado: + + ```csharp + builder.Property(user => user.Version).IsRowVersion(); + ``` + + Quando as versões não batem, o EF Core lança `DbUpdateConcurrencyException`, e a unidade de trabalho a transforma num `Result` de falha com a mensagem "Este cadastro foi alterado por {nome} enquanto você editava. Recarregue para ver a versão atual." Na API, a versão vai no cabeçalho `ETag` e volta no `If-Match`. +2. **Que problema resolve.** Duas pessoas abrem o mesmo cadastro. Sem controle, a segunda a salvar apaga o trabalho da primeira sem perceber. +3. **O que acontece sem isso.** Vale o último que salvou, em silêncio. +4. **Quanto custa.** A tela precisa guardar a versão, reenviá-la e tratar o erro 409. +5. **Alternativas mais simples.** Não tratar. Ou travar o registro enquanto alguém edita (concorrência pessimista), que é pior: a trava fica presa se a pessoa fechar o navegador. +6. **Por que escolhemos assim.** É uma regra de negócio do sistema (CNV-12 e CNV-13), e "otimista" significa apostar que o conflito é raro: ninguém espera, e só o caso raro recebe um aviso. + +- **Onde ver no código:** [UnitOfWork.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/UnitOfWork.cs), [AuditedAggregate.cs](../backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs). As regras estão em [product/conventions.md](product/conventions.md) e em [api/conventions.md](api/conventions.md). +- **Em uma frase:** cada registro tem uma versão; se alguém alterou enquanto eu editava, o sistema recusa e avisa quem foi. + +### 14. Campos de auditoria preenchidos automaticamente + +1. **O que é.** Todo agregado herda de `AuditedAggregate`, que tem `CreatedAt`, `CreatedBy`, `UpdatedAt` e `UpdatedBy`. Quem preenche é um *interceptor* do EF Core: uma classe que o EF chama logo antes de cada `SaveChanges`. + + ```csharp + if (entry.State == EntityState.Added) + { + entry.Property(nameof(AuditedAggregate.CreatedAt)).CurrentValue = now; + entry.Property(nameof(AuditedAggregate.CreatedBy)).CurrentValue = userId; + } + ``` + +2. **Que problema resolve.** Nenhum handler precisa lembrar de gravar quem alterou e quando. Salvar sem um usuário logado lança uma exceção, então nada é gravado sem autor. +3. **O que acontece sem isso.** Cada método do domínio receberia "quem" e "quando" e preencheria os campos. Um dia alguém esqueceria. +4. **Quanto custa.** É a peça que age "por fora": lendo o handler, não se vê os campos sendo preenchidos. E tem um trecho delicado: quando só muda uma lista interna do agregado (os perfis de um usuário, que ficam em outra tabela), o interceptor força o `UPDATE` da linha principal, para a versão da decisão 13 mudar também. +5. **Alternativas mais simples.** Preencher os campos dentro dos métodos do domínio, de forma explícita. +6. **Por que escolhemos assim.** A regra CNV-10 exige esses campos em todo cadastro, e o sistema terá muitos. O automático não depende de memória. Reavaliada em 2026-10-05: mantida. + +- **Onde ver no código:** [AuditFieldsInterceptor.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/AuditFieldsInterceptor.cs), [AuditedAggregate.cs](../backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs). +- **Em uma frase:** "quem criou" e "quem alterou" são gravados num ponto só, antes de todo salvamento, e não em cada handler. + +### 15. Desativar em vez de excluir + +1. **O que é.** Um usuário nunca é apagado do banco. `user.Deactivate(by, now)` muda o status para inativo e grava quem desativou e quando; `user.Reactivate()` desfaz. +2. **Que problema resolve.** Os campos "criado por" e "alterado por" de outros registros apontam para usuários. Apagar um usuário quebraria esse histórico. +3. **O que acontece sem isso.** Ou o `DELETE` falha por causa da chave estrangeira, ou o histórico perde o autor. +4. **Quanto custa.** O login e as consultas precisam considerar o status. +5. **Alternativas mais simples.** O `DELETE` de verdade. +6. **Por que escolhemos assim.** É regra de negócio, e não preferência técnica. Vale para usuários; cada cadastro futuro define o seu caso no documento da funcionalidade. + +- **Onde ver no código:** [User.cs](../backend/ControlService/src/ControlService.Domain/Users/User.cs). +- **Em uma frase:** quem já assinou alterações no sistema não pode sumir do banco, então a conta é desativada, e não apagada. + +### 16. Registros do sistema: criados na inicialização e protegidos + +1. **O que é.** O usuário Admin e o perfil Gerenciador têm ids fixos (`SystemIds`). O `SystemRecordsSeeder` os insere quando o banco é criado ou migrado, e só se estiverem faltando. O agregado os protege com a marca `IsSystem`: + + ```csharp + private Result EnsureNotSystem() => IsSystem + ? Result.Failure(new Error( + "system_record", + "O usuário Admin é do sistema e não pode ser alterado nem desativado.")) + : Result.Success(); + ``` + +2. **Que problema resolve.** Um banco vazio precisa de alguém que consiga entrar. E ninguém pode se trancar para fora do sistema desativando o Admin. +3. **O que acontece sem isso.** Um script manual depois de cada instalação, e a proteção dependeria de cada tela lembrar de conferir. +4. **Quanto custa.** O e-mail e a senha inicial do Admin vêm da configuração, e a aplicação não inicia sem eles. +5. **Alternativas mais simples.** Inserir os registros dentro da própria migration, o que deixaria a senha inicial no repositório. +6. **Por que escolhemos assim.** Reiniciar a aplicação nunca duplica nem altera esses registros, a senha fica fora do código, e a proteção está no domínio, onde vale para qualquer endpoint. + +- **Onde ver no código:** [SystemRecordsSeeder.cs](../backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs), [SystemIds.cs](../backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs), [User.cs](../backend/ControlService/src/ControlService.Domain/Users/User.cs). +- **Em uma frase:** o Admin e o perfil Gerenciador nascem com o banco e o próprio domínio impede que sejam alterados. + +### 17. Listas paginadas no servidor + +1. **O que é.** A tela pede só o que vai mostrar ("página 1, 10 por página, busca 'silva'"). O servidor filtra, ordena, conta e devolve `{ items, page, pageSize, totalCount }`. **Ainda não está construído:** a primeira lista chega com o cadastro de usuários. Por enquanto é a regra API-07 do contrato. +2. **Que problema resolve.** Não se envia ao navegador mil registros para mostrar dez. +3. **O que acontece sem isso.** A tela baixa tudo e filtra em JavaScript. Funciona com 50 registros e trava com 5.000. +4. **Quanto custa.** Cada lista recebe parâmetros de página, busca e ordenação, e faz duas consultas: os itens e o total. +5. **Alternativas mais simples.** Devolver a lista inteira. +6. **Por que escolhemos assim.** O front-end já foi desenhado com esse formato, e mudar o contrato de uma lista depois que a tela existe custa mais do que nascer paginada. + +- **Onde ver no código:** ainda não há; a regra está em [api/conventions.md](api/conventions.md). +- **Em uma frase:** o servidor devolve só a página pedida e o total, e o navegador nunca recebe o que não vai mostrar. + ## O que ficou de fora Avaliado e não adotado. Fica registrado para ninguém propor de novo sem um motivo novo. | Ideia | Por que não | |---|---| +| Dapper para relatórios | O EF Core com `Select` resolve; só entra se um relatório provar que precisa | +| Guardar assinaturas em armazenamento de objetos (MinIO) | Uma coluna no banco basta, se e quando uma tela precisar da assinatura | +| Usar o `DbContext` direto nos handlers | A `Application` passaria a depender do EF Core, contra a regra de camadas (decisão 2) | | Guardar um `openapi.json` no repositório e conferi-lo no CI | A página `/scalar` já mostra o contrato; seria um arquivo a mais para manter | | MediatR e AutoMapper | Duas dependências, hoje com edição comercial, para algo que cabe em poucas linhas do próprio projeto | | Controllers | Nenhum ganho de funcionalidade; exigiria reescrever o que já está testado | From 90d66447d2db10d66f42b67421238cb259170ce3 Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:34:01 -0300 Subject: [PATCH 3/8] docs: add the sign-in and permissions block to the decisions document --- docs/decisoes-de-arquitetura.md | 216 ++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index 4d8b7f2..e53ce76 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -370,6 +370,213 @@ As sete primeiras também são o que permite testar um handler com um repositór - **Onde ver no código:** ainda não há; a regra está em [api/conventions.md](api/conventions.md). - **Em uma frase:** o servidor devolve só a página pedida e o total, e o navegador nunca recebe o que não vai mostrar. +## Bloco C — Login e permissões + +### O que já existe e o que falta + +Este bloco descreve o desenho decidido. Parte dele ainda não está no código, e cada decisão diz qual. + +| Peça | Estado | +|---|---| +| Login, sessão, renovação, sair, `GET /me` | Construído e testado | +| Bloqueio por tentativas e limite de requisições no login | Construído e testado | +| Cálculo do nível por tela e chaves de tela | Construído e testado | +| Troca obrigatória da senha | Construída, mas hoje só o Admin passa por ela; o desenho decidido é o contrário (decisão 21) | +| Autorização por tela e conferência da conta a cada requisição | **Não construída** (issue #9). Hoje qualquer pessoa logada chama qualquer endpoint | +| Rota com o catálogo de telas (`GET /screens`) | **Não construída** (issue #10) | + +### 18. Senhas guardadas pelo ASP.NET Core Identity, separadas do usuário + +1. **O que é.** O Identity é a biblioteca da Microsoft para contas. O projeto usa só o núcleo dela: gerar o *hash* da senha (o embaralhamento sem volta que se guarda no lugar da senha) e contar as tentativas erradas. A credencial fica numa tabela própria (`UserCredential`), com o mesmo id do `User`, e o handler fala com ela pela interface `ICredentialStore`: + + ```csharp + if (await users.CheckPasswordAsync(credential, password)) + { + await users.ResetAccessFailedCountAsync(credential); + return CredentialCheck.Succeeded; + } + + await users.AccessFailedAsync(credential); + ``` + +2. **Que problema resolve.** Guardar senha com segurança é fácil de errar, e o Identity já faz certo. Com a credencial fora do cadastro, a senha nunca aparece numa consulta de usuários nem numa resposta da API. +3. **O que acontece sem isso.** O hash e o bloqueio seriam escritos à mão. +4. **Quanto custa.** A tabela vem com colunas que o sistema não usa (o e-mail e o telefone do Identity ficam vazios). E há uma classe que só existe por causa dos testes: `CredentialUserManager` faz o bloqueio usar o relógio da aplicação, que os testes controlam. +5. **Alternativas mais simples.** Uma coluna `PasswordHash` no próprio `User` e o contador de tentativas feito no projeto. +6. **Por que escolhemos assim.** Não se escreve criptografia de senha à mão. O `SignInManager` do Identity ficou de fora porque traz junto a autenticação por cookie, que o sistema não usa. + +- **Onde ver no código:** [CredentialStore.cs](../backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialStore.cs), [UserCredential.cs](../backend/ControlService/src/ControlService.Infrastructure/Auth/UserCredential.cs), [ICredentialStore.cs](../backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs). +- **Em uma frase:** não escrevo criptografia de senha; uso a da Microsoft e guardo a credencial fora do cadastro. + +### 19. Token de acesso JWT de 15 minutos + +1. **O que é.** Depois do login, a API devolve um JWT: um texto assinado que diz quem é a pessoa. O navegador o envia em cada requisição, e o ASP.NET confere a assinatura sem ir ao banco. O token carrega três *claims* (campos): `sub`, o id do usuário; `sid`, o id da sessão; e `must_change_password`, só enquanto a troca de senha é obrigatória. + + ```csharp + var claims = new Dictionary + { + [JwtRegisteredClaimNames.Sub] = userId.ToString(), + [SessionIdClaim] = sessionId.ToString(), + }; + ``` + + Não há dado pessoal nem permissão dentro do token. +2. **Que problema resolve.** A API precisa saber quem faz cada requisição sem pedir a senha de novo. +3. **O que acontece sem isso.** A alternativa clássica é o cookie de sessão do próprio ASP.NET. +4. **Quanto custa.** Um token emitido vale até vencer; não dá para cancelá-lo. Por isso ele dura pouco e precisa da decisão 20. A chave que assina os tokens é um segredo, fica fora do repositório, e a aplicação não inicia sem ela. +5. **Alternativas mais simples.** Autenticação só por cookie, que tem menos peças e serviria para este front-end. +6. **Por que escolhemos assim.** É o formato mais pedido em vagas e serve para um aplicativo de celular no futuro. As permissões ficam fora do token de propósito: se estivessem dentro, mudar um perfil só valeria quando o token vencesse. + +- **Onde ver no código:** [JwtAccessTokenIssuer.cs](../backend/ControlService/src/ControlService.API/Auth/JwtAccessTokenIssuer.cs), [ConfigureJwtBearer.cs](../backend/ControlService/src/ControlService.API/Auth/ConfigureJwtBearer.cs). +- **Em uma frase:** o token diz só quem é a pessoa e dura 15 minutos; o que ela pode fazer eu leio do banco. + +### 20. Sessão com refresh token no banco, trocado a cada uso + +1. **O que é.** Junto do JWT vai um *refresh token*: 32 bytes aleatórios que servem só para pedir um JWT novo. No banco fica apenas o hash dele, na tabela `UserSession`. Ele viaja num cookie `HttpOnly` (que o JavaScript da página não consegue ler), enviado só para a rota de renovação. A cada uso ele é trocado por outro, que vale por mais 8 horas: + + ```csharp + // Um UPDATE só: duas renovações com o mesmo token nunca passam as duas. + var rotated = await db.Sessions + .Where(session => session.TokenHash == oldHash && session.ExpiresAt > now) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(session => session.TokenHash, newHash) + .SetProperty(session => session.ExpiresAt, expiresAt), + cancellationToken); + ``` + +2. **Que problema resolve.** Com o JWT curto, a pessoa teria de entrar de novo a cada 15 minutos. E é o refresh token que permite encerrar uma sessão: sair ou trocar a senha apaga a linha, e a renovação seguinte falha. +3. **O que acontece sem isso.** Ou um JWT longo, que não se cancela, ou login repetido. +4. **Quanto custa.** É a parte mais difícil de explicar do login: dois tokens, um cookie e a troca a cada uso. O front-end precisa renovar quando o token de acesso vence. +5. **Alternativas mais simples.** Um JWT de 8 horas, sem refresh token. Ou o cookie de sessão da decisão 19. +6. **Por que escolhemos assim.** "Oito horas sem nenhuma ação encerram a sessão" é o comportamento que o protótipo do front-end já simula. O front-end renova só quando precisa fazer uma requisição, nunca por temporizador, para que "sem ação" seja mesmo sem ação da pessoa. + +- **Onde ver no código:** [SessionStore.cs](../backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs), [RefreshCookie.cs](../backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs), [RefreshSessionHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/RefreshSession/RefreshSessionHandler.cs). +- **Em uma frase:** o token curto prova quem sou; o longo fica no banco, onde posso cancelá-lo. + +### 21. Senha temporária com troca obrigatória; o Admin é a conta de demonstração + +1. **O que é.** Duas regras, decididas pelo dono em 2026-10-05: + - **Usuário cadastrado.** Quem cadastra define uma senha temporária. A credencial nasce marcada (`MustChangePassword`), o JWT sai com o claim `must_change_password`, e um filtro no grupo `/api/v1` recusa tudo, menos `me`, sair e trocar a senha: + + ```csharp + return isPending && !mayProceed + ? ValueTask.FromResult(AuthErrors.PasswordChangeRequired.ToProblem()) + : next(context); + ``` + + - **Admin.** Nesta fase de portfólio, o Admin é a conta de demonstração: o login e a senha dele ficam à mostra na tela de entrada, para qualquer visitante explorar o sistema inteiro. Por isso o Admin **não** passa pela troca obrigatória e não troca a senha. +2. **Que problema resolve.** Quem cadastrou conhece a senha temporária, então ela não pode continuar valendo. E um avaliador precisa entrar no sistema sem pedir acesso a ninguém. +3. **O que acontece sem isso.** O primeiro acesso dependeria de um link enviado por e-mail: servidor de e-mail, tokens de ativação e mais telas, antes de o produto ter um ciclo completo. +4. **Quanto custa.** A senha temporária é passada por fora do sistema. Não existe "Esqueci minha senha". E uma conta com todas as permissões é pública: um visitante pode excluir perfis e desativar usuários. O dono aceita isso enquanto o sistema for um portfólio; o que nunca se perde são o Admin e o perfil Gerenciador, que o domínio protege (decisão 16). +5. **Alternativas mais simples.** Esta já é a mais simples. A mais completa é o link de ativação por e-mail, adiado até o ciclo do produto estar fechado. +6. **Por que escolhemos assim.** O objetivo agora é um produto que possa ser testado do início ao fim. O que precisa mudar antes de um uso real está em [Antes de ir para o mundo real](#antes-de-ir-para-o-mundo-real). + +**O código ainda reflete o plano antigo.** Hoje é o contrário do decidido: só o Admin nasce com a troca obrigatória, e os usuários cadastrados ainda não recebem senha (a issue #11 traz o cadastro). Também sobram do plano antigo `User.Activate` com o erro `link_invalid`, a mensagem que manda usar "Esqueci minha senha", e as regras AUTH-14, AUTH-15 e USR-34 em [product/](product/), que serão reescritas. + +- **Onde ver no código:** [PasswordChangeRequiredFilter.cs](../backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs), [ApiV1Group.cs](../backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs), [ChangePasswordHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs). +- **Em uma frase:** a primeira senha é de quem cadastrou, então o sistema obriga a trocá-la antes de liberar qualquer tela. + +### 22. Bloqueio por tentativas e limite de requisições só no login + +1. **O que é.** Duas proteções diferentes: + - **Bloqueio por conta.** Cinco senhas erradas seguidas travam aquele login por 15 minutos. Quem conta é o Identity (decisão 18). + - **Limite por endereço** (*rate limit*). Cada endereço IP pode fazer 20 logins e 60 renovações por minuto. Acima disso, a resposta é 429, com o cabeçalho `Retry-After` dizendo quanto esperar. Quem faz é o middleware que já vem no ASP.NET: + + ```csharp + api.MapPost("/auth/sign-in", SignIn) + .AllowAnonymous() + .RequireRateLimiting(AuthRateLimiting.SignInPolicy) + ``` + +2. **Que problema resolve.** O bloqueio protege uma conta de quem tenta adivinhar a senha dela. O limite protege de quem testa a mesma senha em muitas contas, que o bloqueio não enxerga. +3. **O que acontece sem isso.** O login aceitaria tentativas sem fim. +4. **Quanto custa.** Um arquivo e dois valores de configuração. Várias pessoas atrás do mesmo endereço de escritório dividem o limite. +5. **Alternativas mais simples.** Só o bloqueio por conta. +6. **Por que escolhemos assim.** As duas rotas limitadas são as únicas que aceitam requisição sem login. O limite global, para o resto da API, foi descartado: as outras rotas já exigem um token válido. + +- **Onde ver no código:** [AuthRateLimiting.cs](../backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs), [CredentialUserManager.cs](../backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs). +- **Em uma frase:** errar a senha cinco vezes trava a conta; insistir demais no login trava o endereço. + +### 23. Permissão por tela, em quatro níveis, pelo maior nível entre os perfis + +1. **O que é.** Cada perfil de permissão dá um nível para cada tela: negado, leitor, editor ou gerenciador. Cada nível inclui o que o anterior permite. Uma pessoa pode ter vários perfis, e em cada tela vale o maior nível entre eles; uma tela que nenhum perfil menciona fica negada. + + ```csharp + var levels = profiles.Select(profile => profile.GetLevel(screen)); + return levels.DefaultIfEmpty(AccessLevel.Denied).Max(); + ``` + + O `GET /me` devolve o nível da pessoa em todas as telas. +2. **Que problema resolve.** É a regra do negócio (PERM-01 a PERM-07): o responsável pela empresa decide, tela a tela, o que cada pessoa pode fazer. +3. **O que acontece sem isso.** Com papéis fixos, como `[Authorize(Roles = "Admin")]`, cada combinação de tela e nível seria um papel escrito no código. +4. **Quanto custa.** Calcular o nível exige carregar os perfis da pessoa. +5. **Alternativas mais simples.** Dois ou três papéis fixos. +6. **Por que escolhemos assim.** É regra do produto, e o cálculo é uma função sem banco, testada no domínio. "Negado" é ausência de permissão, e não proibição: acrescentar um perfil nunca tira acesso de ninguém. + +- **Onde ver no código:** [EffectiveAccess.cs](../backend/ControlService/src/ControlService.Domain/Access/EffectiveAccess.cs), [AccessLevel.cs](../backend/ControlService/src/ControlService.Domain/Access/AccessLevel.cs), [GetMeHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/GetMe/GetMeHandler.cs). As regras estão em [product/features/permission-profiles.md](product/features/permission-profiles.md). +- **Em uma frase:** a permissão é um nível por tela, e com vários perfis vale o mais alto. + +### 24. Autorização conferida no endpoint, lida do banco a cada requisição + +**Ainda não está construído:** chega com a issue #9. Os nomes abaixo são os planejados. + +1. **O que é.** Cada endpoint declara a tela e o nível mínimo que exige: + + ```csharp + api.MapPost("/users", Create).RequireScreenAccess(ScreenKeys.Users, AccessLevel.Editor); + ``` + + Por trás dessa linha há um *filtro de endpoint*: uma classe que roda antes do método do endpoint e pode responder no lugar dele. É o mesmo molde do filtro da decisão 21. Serão dois: + - um no grupo `/api/v1`, que confere se a conta está ativa e responde 401 `account_inactive` se não estiver; + - um por endpoint, que lê os perfis da pessoa, calcula o nível (decisão 23) e responde 403 se for menor que o exigido. + + Os dois leem o banco a cada requisição, sem cache. A tabela vale para todas as telas: + + | Operação | Nível mínimo | + |---|---| + | Listar e ver | Leitor | + | Criar e editar | Editor | + | Excluir ou desativar (e reativar) qualquer cadastro | Gerenciador | + + No front-end, a tela com nível negado não aparece no menu, e os botões seguem a mesma tabela. Isso é conforto, não segurança: quem recusa de verdade é a API. +2. **Que problema resolve.** A regra de permissão fica num lugar só, e nenhuma tela precisa lembrar de conferi-la. Mudar um perfil ou desativar alguém vale já na requisição seguinte. +3. **O que acontece sem isso.** É o estado de hoje: qualquer pessoa logada chama qualquer endpoint, e quem é desativado continua com acesso até o token vencer (até 15 minutos). +4. **Quanto custa.** Duas consultas a mais por requisição (o usuário e os perfis dele). Para um ERP de uma empresa, não pesa. +5. **Alternativas mais simples.** Conferir a permissão dentro de cada handler, o que um dia alguém esquece. Ou pôr as permissões no JWT (decisão 19). +6. **Por que escolhemos assim.** O filtro devolve o erro do mesmo jeito que o resto da API (decisão 7) e não traz conceito novo. A alternativa oficial do ASP.NET, *requirement* com *authorization handler* (o que fica por trás de `[Authorize(Policy = "...")]`), exigiria duas classes, e ela só sabe responder 403, o que encaixa mal no 401 da conta desativada. O cache de permissões foi descartado em 2026-10-05: seria uma peça a mais para invalidar, a fim de economizar duas consultas. + +- **Onde ver no código:** ainda não há; o filtro que serve de molde é [PasswordChangeRequiredFilter.cs](../backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs). A tabela de níveis é a regra PERM-03 em [product/features/permission-profiles.md](product/features/permission-profiles.md). +- **Em uma frase:** cada endpoint diz a tela e o nível que exige, e a resposta vem do banco na hora, sem cache. + +### 25. Chaves de tela como constantes estáveis + +1. **O que é.** Cada tela tem uma chave fixa, numa classe do domínio, e é ela que fica gravada nas permissões: + + ```csharp + public const string Users = "gerenciamento/usuarios"; + ``` + + O nome que aparece no menu pode mudar; a chave, nunca. Uma chave aposentada não é reaproveitada. +2. **Que problema resolve.** No protótipo, a chave era derivada do nome da tela. Renomear "Relatório de Vendas" mudaria a chave, nenhum perfil teria nível para a chave nova, e a tela sumiria para todos, sem aviso. +3. **O que acontece sem isso.** Corrigir um acento num nome de menu apagaria permissões. +4. **Quanto custa.** Uma tela nova exige uma constante no back-end e uma entrada no menu do front-end. +5. **Alternativas mais simples.** Derivar a chave do nome. Ou usar ids numéricos, que são estáveis, mas ilegíveis no banco. +6. **Por que escolhemos assim.** Separar o identificador do texto exibido custa uma linha por tela. A rota que entrega o catálogo de telas ao front-end (`GET /screens`) chega com a issue #10. + +- **Onde ver no código:** [ScreenKeys.cs](../backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs), [ScreenKey.cs](../backend/ControlService/src/ControlService.Domain/Access/ScreenKey.cs), [screen-catalog.json](product/screen-catalog.json). +- **Em uma frase:** a permissão aponta para um código que nunca muda, e não para o nome que aparece no menu. + +## Antes de ir para o mundo real + +O sistema é, por enquanto, um portfólio: precisa ser fácil de visitar e ter o ciclo testável do início ao fim. O que está abaixo é aceito nesta fase e precisa ser revisto antes de um uso real. + +| Hoje, no portfólio | Antes de um uso real | +|---|---| +| O login e a senha do Admin ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha da tela e definir como o Admin troca a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | +| Não existe "Esqueci minha senha" | Links de ativação e de redefinição por e-mail | +| A senha temporária é passada por fora do sistema | O mesmo link de ativação | + ## O que ficou de fora Avaliado e não adotado. Fica registrado para ninguém propor de novo sem um motivo novo. @@ -382,3 +589,12 @@ Avaliado e não adotado. Fica registrado para ninguém propor de novo sem um mot | Guardar um `openapi.json` no repositório e conferi-lo no CI | A página `/scalar` já mostra o contrato; seria um arquivo a mais para manter | | MediatR e AutoMapper | Duas dependências, hoje com edição comercial, para algo que cabe em poucas linhas do próprio projeto | | Controllers | Nenhum ganho de funcionalidade; exigiria reescrever o que já está testado | +| Cache de permissões (HybridCache, Redis) | Ler o usuário e os perfis a cada requisição custa duas consultas; o cache seria uma peça a mais para invalidar (decisão 24) | +| Links de ativação e de redefinição de senha por e-mail | Adiado, não descartado: volta quando o ciclo do produto estiver fechado (decisão 21) | +| Limite global de requisições | Só as rotas sem login são limitadas; as outras já exigem um token válido (decisão 22) | +| Permissões dentro do JWT | Mudar um perfil só valeria quando o token vencesse, e o token cresceria a cada tela nova | +| Papéis fixos (`Roles`) | Não expressam "um nível por tela" sem um papel para cada combinação | +| *Requirement* e *authorization handler* do ASP.NET | Dois conceitos a mais para o mesmo resultado de um filtro, e só sabem responder 403 (decisão 24) | +| Autenticação só por cookie | Serviria para este front-end, mas o JWT é o mais pedido em vagas e serve para um aplicativo de celular | +| Provedor de identidade externo (Keycloak, Auth0) | Tiraria do código a parte de login, que é uma das que o portfólio quer mostrar | +| `SignInManager` e `MapIdentityApi` do Identity | Trazem autenticação por cookie e rotas prontas que não emitem o JWT do projeto | From 85cab57e21b1de6cf7613acda3ecf9f06c58f466 Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:40:06 -0300 Subject: [PATCH 4/8] docs: record the fixed demo credentials of the Admin --- docs/decisoes-de-arquitetura.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index e53ce76..cb1dd28 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -465,14 +465,14 @@ Este bloco descreve o desenho decidido. Parte dele ainda não está no código, : next(context); ``` - - **Admin.** Nesta fase de portfólio, o Admin é a conta de demonstração: o login e a senha dele ficam à mostra na tela de entrada, para qualquer visitante explorar o sistema inteiro. Por isso o Admin **não** passa pela troca obrigatória e não troca a senha. + - **Admin.** Nesta fase de portfólio, o Admin é a conta de demonstração: o login `admin` e a senha `admin` são fixos e ficam à mostra na tela de entrada, para qualquer visitante explorar o sistema inteiro. Por isso o Admin **não** passa pela troca obrigatória e não troca a senha. 2. **Que problema resolve.** Quem cadastrou conhece a senha temporária, então ela não pode continuar valendo. E um avaliador precisa entrar no sistema sem pedir acesso a ninguém. 3. **O que acontece sem isso.** O primeiro acesso dependeria de um link enviado por e-mail: servidor de e-mail, tokens de ativação e mais telas, antes de o produto ter um ciclo completo. 4. **Quanto custa.** A senha temporária é passada por fora do sistema. Não existe "Esqueci minha senha". E uma conta com todas as permissões é pública: um visitante pode excluir perfis e desativar usuários. O dono aceita isso enquanto o sistema for um portfólio; o que nunca se perde são o Admin e o perfil Gerenciador, que o domínio protege (decisão 16). 5. **Alternativas mais simples.** Esta já é a mais simples. A mais completa é o link de ativação por e-mail, adiado até o ciclo do produto estar fechado. 6. **Por que escolhemos assim.** O objetivo agora é um produto que possa ser testado do início ao fim. O que precisa mudar antes de um uso real está em [Antes de ir para o mundo real](#antes-de-ir-para-o-mundo-real). -**O código ainda reflete o plano antigo.** Hoje é o contrário do decidido: só o Admin nasce com a troca obrigatória, e os usuários cadastrados ainda não recebem senha (a issue #11 traz o cadastro). Também sobram do plano antigo `User.Activate` com o erro `link_invalid`, a mensagem que manda usar "Esqueci minha senha", e as regras AUTH-14, AUTH-15 e USR-34 em [product/](product/), que serão reescritas. +**O código ainda reflete o plano antigo.** Hoje é o contrário do decidido: só o Admin nasce com a troca obrigatória, e os usuários cadastrados ainda não recebem senha (a issue #11 traz o cadastro). A senha inicial do Admin ainda vem da configuração (`Admin:InitialPassword`) e precisa ter o tamanho mínimo de 8 caracteres, que `admin` não tem. Também sobram do plano antigo `User.Activate` com o erro `link_invalid`, a mensagem que manda usar "Esqueci minha senha", e as regras AUTH-14, AUTH-15 e USR-34 em [product/](product/), que serão reescritas. - **Onde ver no código:** [PasswordChangeRequiredFilter.cs](../backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs), [ApiV1Group.cs](../backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs), [ChangePasswordHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs). - **Em uma frase:** a primeira senha é de quem cadastrou, então o sistema obriga a trocá-la antes de liberar qualquer tela. @@ -573,7 +573,7 @@ O sistema é, por enquanto, um portfólio: precisa ser fácil de visitar e ter o | Hoje, no portfólio | Antes de um uso real | |---|---| -| O login e a senha do Admin ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha da tela e definir como o Admin troca a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | +| O login `admin` e a senha `admin` são fixos e ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha fixa do código e da tela, e permitir que o Admin troque a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | | Não existe "Esqueci minha senha" | Links de ativação e de redefinição por e-mail | | A senha temporária é passada por fora do sistema | O mesmo link de ativação | From 4affdce75322882aa5ceb628d9d377194a7147e7 Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:42:08 -0300 Subject: [PATCH 5/8] docs: set the demo password of the Admin to eight characters --- docs/decisoes-de-arquitetura.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index cb1dd28..c74221c 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -465,14 +465,14 @@ Este bloco descreve o desenho decidido. Parte dele ainda não está no código, : next(context); ``` - - **Admin.** Nesta fase de portfólio, o Admin é a conta de demonstração: o login `admin` e a senha `admin` são fixos e ficam à mostra na tela de entrada, para qualquer visitante explorar o sistema inteiro. Por isso o Admin **não** passa pela troca obrigatória e não troca a senha. + - **Admin.** Nesta fase de portfólio, o Admin é a conta de demonstração: o login `admin` e a senha `admin123` são fixos e ficam à mostra na tela de entrada, para qualquer visitante explorar o sistema inteiro. Por isso o Admin **não** passa pela troca obrigatória e não troca a senha. 2. **Que problema resolve.** Quem cadastrou conhece a senha temporária, então ela não pode continuar valendo. E um avaliador precisa entrar no sistema sem pedir acesso a ninguém. 3. **O que acontece sem isso.** O primeiro acesso dependeria de um link enviado por e-mail: servidor de e-mail, tokens de ativação e mais telas, antes de o produto ter um ciclo completo. 4. **Quanto custa.** A senha temporária é passada por fora do sistema. Não existe "Esqueci minha senha". E uma conta com todas as permissões é pública: um visitante pode excluir perfis e desativar usuários. O dono aceita isso enquanto o sistema for um portfólio; o que nunca se perde são o Admin e o perfil Gerenciador, que o domínio protege (decisão 16). 5. **Alternativas mais simples.** Esta já é a mais simples. A mais completa é o link de ativação por e-mail, adiado até o ciclo do produto estar fechado. 6. **Por que escolhemos assim.** O objetivo agora é um produto que possa ser testado do início ao fim. O que precisa mudar antes de um uso real está em [Antes de ir para o mundo real](#antes-de-ir-para-o-mundo-real). -**O código ainda reflete o plano antigo.** Hoje é o contrário do decidido: só o Admin nasce com a troca obrigatória, e os usuários cadastrados ainda não recebem senha (a issue #11 traz o cadastro). A senha inicial do Admin ainda vem da configuração (`Admin:InitialPassword`) e precisa ter o tamanho mínimo de 8 caracteres, que `admin` não tem. Também sobram do plano antigo `User.Activate` com o erro `link_invalid`, a mensagem que manda usar "Esqueci minha senha", e as regras AUTH-14, AUTH-15 e USR-34 em [product/](product/), que serão reescritas. +**O código ainda reflete o plano antigo.** Hoje é o contrário do decidido: só o Admin nasce com a troca obrigatória, e os usuários cadastrados ainda não recebem senha (a issue #11 traz o cadastro). A senha inicial do Admin ainda vem da configuração (`Admin:InitialPassword`). Também sobram do plano antigo `User.Activate` com o erro `link_invalid`, a mensagem que manda usar "Esqueci minha senha", e as regras AUTH-14, AUTH-15 e USR-34 em [product/](product/), que serão reescritas. - **Onde ver no código:** [PasswordChangeRequiredFilter.cs](../backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs), [ApiV1Group.cs](../backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs), [ChangePasswordHandler.cs](../backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs). - **Em uma frase:** a primeira senha é de quem cadastrou, então o sistema obriga a trocá-la antes de liberar qualquer tela. @@ -573,7 +573,7 @@ O sistema é, por enquanto, um portfólio: precisa ser fácil de visitar e ter o | Hoje, no portfólio | Antes de um uso real | |---|---| -| O login `admin` e a senha `admin` são fixos e ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha fixa do código e da tela, e permitir que o Admin troque a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | +| O login `admin` e a senha `admin123` são fixos e ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha fixa do código e da tela, e permitir que o Admin troque a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | | Não existe "Esqueci minha senha" | Links de ativação e de redefinição por e-mail | | A senha temporária é passada por fora do sistema | O mesmo link de ativação | From 89120bc1058838e904af212649bfb9305eccb406 Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:23:57 -0300 Subject: [PATCH 6/8] docs: add the quality and operations block to the decisions document --- docs/decisoes-de-arquitetura.md | 277 ++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index c74221c..74e2f60 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -567,6 +567,269 @@ Este bloco descreve o desenho decidido. Parte dele ainda não está no código, - **Onde ver no código:** [ScreenKeys.cs](../backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs), [ScreenKey.cs](../backend/ControlService/src/ControlService.Domain/Access/ScreenKey.cs), [screen-catalog.json](product/screen-catalog.json). - **Em uma frase:** a permissão aponta para um código que nunca muda, e não para o nome que aparece no menu. +## Bloco D — Qualidade e operação + +### O que ainda reflete o plano antigo + +As decisões deste bloco descrevem o que está em uso. Estas sobras do plano anterior a 2026-10-05 ainda estão no código e saem em mudanças próprias, fora deste documento: + +| Sobra | Onde está | O que será feito | +|---|---|---| +| NSubstitute, uma biblioteca de mocks que nenhum teste usa | `ControlService.Application.Tests.csproj` | Remover (decisão 27) | +| Mailpit, a caixa de e-mail de teste: o AppHost a sobe e a API espera por ela, mas nada envia e-mail | `AppHost.cs` | Remover; volta quando o e-mail voltar (decisão 31) | +| `MailKit` e `Microsoft.Extensions.Caching.Hybrid`, com versão declarada e nenhum projeto usando | `Directory.Packages.props` | Remover (decisão 30) | +| *Service discovery* e resiliência de HTTP, que vieram do modelo do Aspire | `ServiceDefaults/Extensions.cs` | Remover (decisão 31) | +| Um pacote do Visual Studio em versão *preview*, que só serve para depurar dentro de um contêiner | `ControlService.API.csproj` | Remover (decisão 33) | +| O CI não confere a formatação | `.github/workflows/ci.yml` | Acrescentar o passo (decisão 29) | +| O guia de testes cita o Mailpit e os casos de link de ativação | `docs/agents/guides/testing.md` | Corrigir junto com a troca das citações de ADR | + +### 26. Testes em quatro projetos, cada comportamento em uma camada, contra PostgreSQL de verdade + +1. **O que é.** Quatro projetos de teste, com cerca de 200 testes em outubro de 2026: + + | Projeto | O que testa | Testes | Precisa de | + |---|---|---|---| + | `Domain.Tests` | Value Objects, agregados, `EffectiveAccess` | 94 | Nada | + | `Application.Tests` | Handlers, com fakes (decisão 27) | 34 | Nada | + | `Api.IntegrationTests` | Chamadas HTTP de verdade, login, banco | 69 | Docker | + | `ArchitectureTests` | Regras entre as camadas (decisão 28) | 4 | Nada | + + As ferramentas são o xUnit v3, que é o framework de testes, e o Shouldly, que escreve as conferências (`result.IsSuccess.ShouldBeTrue()`). Os testes de integração sobem a API inteira em memória (`WebApplicationFactory`) contra um PostgreSQL real, num contêiner que o próprio teste liga e desliga (Testcontainers). Há um contêiner só para todos os testes: + + ```csharp + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:18.3").Build(); + ``` + + Cada comportamento é testado em **uma camada só**, a mais barata que o prova: a regra de um Value Object, no domínio; uma operação, pelo endpoint. +2. **Que problema resolve.** A regra de negócio é testada rápido e sem banco. O que depende do banco (índice único, concorrência, chave estrangeira) é testado no mesmo banco que roda em produção. +3. **O que acontece sem isso.** Com o banco em memória do EF Core, um teste de unicidade passaria sem o índice existir, porque esse provedor não aplica índices nem restrições. +4. **Quanto custa.** Os testes de integração exigem o Docker ligado e rodam em sequência, porque dividem o mesmo banco. Cada teste cria os próprios dados, com nomes únicos. +5. **Alternativas mais simples.** O banco em memória ou o SQLite. Ou só testes de unidade. +6. **Por que escolhemos assim.** As regras de negócio são o centro do projeto, e várias delas só existem de verdade dentro do PostgreSQL. + +- **Onde ver no código:** [CpfTests.cs](../backend/ControlService/tests/ControlService.Domain.Tests/Users/CpfTests.cs), [SignInApiTests.cs](../backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs), [PostgresContainerFixture.cs](../backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/PostgresContainerFixture.cs), [tests/Directory.Build.props](../backend/ControlService/tests/Directory.Build.props). +- **Em uma frase:** a regra de negócio eu testo sem banco, e o que depende do banco eu testo num PostgreSQL de verdade, e não numa imitação. + +### 27. Teste antes do código, com lista numerada e fakes em memória + +1. **O que é.** Duas práticas: + - **Teste primeiro** (*test-first*). Todo comportamento começa por um teste que falha; depois vem o código que o faz passar, e por fim a limpeza (Red → Green → Refactor). Cada issue tem uma lista numerada de testes (`T01`, `T02`), que aparece igual na issue, no pull request e no relatório final, com o resultado de cada um. + - **Fakes em memória.** O `SignInHandler` recebe interfaces, como `IUserRepository`. Em produção, quem a implementa é o repositório que fala com o PostgreSQL. No teste de handler, quem a implementa é um *fake*: uma classe do próprio projeto de testes, que guarda os dados numa lista. + + ```csharp + internal sealed class InMemoryUserRepository : IUserRepository + { + private readonly List _users = []; + + public void Add(User user) => _users.Add(user); + + public Task GetByLoginAsync(Login login, CancellationToken cancellationToken) => + Task.FromResult(_users.Find(user => user.Login.Value == login.Value)); + } + ``` + + O teste confere o **resultado**, e não as chamadas internas: + + ```csharp + var bed = new AuthTestBed(); + var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); + + var result = await bed.CreateSignInHandler() + .Handle(new SignInCommand("ana.souza", "senha-da-ana"), TestContext.Current.CancellationToken); + + result.IsSuccess.ShouldBeTrue(); + var session = bed.Sessions.SessionsOf(user.Id).ShouldHaveSingleItem(); + ``` + +2. **Que problema resolve.** O teste escrito antes descreve a regra, e não o código que já existe. E o fake deixa refatorar o handler: o teste só quebra se o resultado mudar. +3. **O que acontece sem isso.** Testes escritos depois tendem a confirmar o que o código já faz. Com um *mock* (um objeto que uma biblioteca fabrica durante o teste, e que confere quais métodos foram chamados), o teste quebra quando se troca o método chamado, mesmo com o resultado igual. +4. **Quanto custa.** Mais tempo por funcionalidade, e uma classe fake para cada interface da `Application`; hoje são sete. O fake não prova que o SQL funciona: o repositório de verdade é testado contra o PostgreSQL (decisão 26). +5. **Alternativas mais simples.** Testar depois. Teste primeiro só no domínio. Mocks em todos os testes. +6. **Por que escolhemos assim.** O conjunto de testes é a rede de segurança para refatorar, e precisa sobreviver às refatorações. Em 2026-10-05 a forma de trabalho mudou: antes havia uma pausa a cada teste, para o dono aprovar; agora a issue é executada de uma vez, e a evidência é o relatório final. A regra do teste primeiro não mudou. Uma peça sem um teste que possa falhar antes (configuração, migrations) é nomeada na issue junto com o teste que a cobre. + +- **Onde ver no código:** a pasta [Fakes](../backend/ControlService/tests/ControlService.Application.Tests/Fakes/), [AuthTestBed.cs](../backend/ControlService/tests/ControlService.Application.Tests/Auth/AuthTestBed.cs), [SignInTests.cs](../backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs). As regras de trabalho estão no [AGENTS.md](../AGENTS.md#tests). +- **Em uma frase:** escrevo o teste antes do código, e no lugar do banco uso uma classe simples em memória, para o teste conferir o resultado e não as chamadas. + +### 28. Testes de arquitetura + +1. **O que é.** Testes que leem os projetos já compilados e falham se uma camada usar o que não pode. Usam o pacote NetArchTest: + + ```csharp + [Fact] + public void Domain_does_not_depend_on_other_layers_or_frameworks() => + AssertNoDependency(DomainAssembly, + ApplicationNamespace, InfrastructureNamespace, ApiNamespace, EntityFrameworkCore, AspNetCore); + ``` + + | Regra | O que proíbe | + |---|---| + | Domínio | Usar `Application`, `Infrastructure`, `API`, EF Core ou ASP.NET Core | + | Application | Usar `Infrastructure`, `API`, EF Core ou ASP.NET Core | + | Infrastructure | Usar a `API` | + | Agregados | Ter uma propriedade com `set` público | + +2. **Que problema resolve.** A regra de camadas (decisão 2) e a do agregado que protege os próprios dados (decisão 3) deixam de depender de atenção na revisão. +3. **O que acontece sem isso.** As referências entre projetos impedem uma parte: o `Domain` não referencia nenhum outro projeto. Mas nada impediria a `Application` de usar um pacote do EF Core, e isso só seria notado meses depois. +4. **Quanto custa.** Um pacote e um projeto com quatro testes. A lista de agregados do último teste é manual: um agregado novo precisa ser acrescentado a ela. +5. **Alternativas mais simples.** Só a revisão de código. +6. **Por que escolhemos assim.** São poucas linhas para uma garantia que não depende de memória. **Planejada:** uma quinta regra, que proíbe a `API` de usar tipos da `Infrastructure` fora do `Program.cs`, para nenhum endpoint consultar o banco sem passar por um handler. Hoje o código já a cumpre; o teste entra numa issue própria, antes dos próximos endpoints. + +- **Onde ver no código:** [LayerDependencyTests.cs](../backend/ControlService/tests/ControlService.ArchitectureTests/LayerDependencyTests.cs), [AggregateTests.cs](../backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs). +- **Em uma frase:** as regras entre as camadas são testes: quem as viola quebra o build. + +### 29. As mesmas regras de qualidade em todos os projetos + +1. **O que é.** Um arquivo, o `Directory.Build.props`, vale para todos os projetos da solução: + + ```xml + enable + true + latest-recommended + true + ``` + + | Opção | O que faz | + |---|---| + | `Nullable` | O compilador avisa onde um valor pode ser `null` | + | `TreatWarningsAsErrors` | Um aviso quebra o build | + | `AnalysisLevel` | Liga os analisadores recomendados do .NET, que apontam erros comuns | + | `EnforceCodeStyleInBuild` | As regras de estilo do `.editorconfig` (campo privado com `_`, por exemplo) valem no build, e não só no editor | + + A **formatação** é o texto do código: espaços, recuo, linhas em branco, ordem dos `using`. O padrão está no `.editorconfig`, e o comando `dotnet format` reescreve os arquivos para segui-lo. O script `check.ps1` formata, compila e testa de uma vez. +2. **Que problema resolve.** Os avisos não se acumulam, e um projeto novo já nasce com as regras. +3. **O que acontece sem isso.** Cada projeto teria as suas opções, e os avisos de `null` seriam ignorados até virarem `NullReferenceException`. +4. **Quanto custa.** Um aviso pequeno trava o trabalho até ser corrigido. Só se suprime um aviso no próprio local, com a justificativa. +5. **Alternativas mais simples.** Deixar os avisos como avisos. Configurar cada projeto. +6. **Por que escolhemos assim.** Um aviso que não quebra o build acaba ignorado. + +**O CI ainda não confere a formatação.** Hoje só o `check.ps1`, na máquina de quem desenvolve, cuida dela. Um passo com `dotnet format --verify-no-changes`, que confere sem alterar, será acrescentado ao CI. + +- **Onde ver no código:** [Directory.Build.props](../backend/ControlService/Directory.Build.props), [.editorconfig](../backend/ControlService/.editorconfig), [check.ps1](../backend/ControlService/check.ps1). +- **Em uma frase:** as regras de qualidade ficam num arquivo só, valem para todos os projetos, e um aviso quebra o build. + +### 30. As versões dos pacotes num arquivo só, com auditoria e atualização automática + +1. **O que é.** Três peças: + - **Versões centralizadas.** O `Directory.Packages.props` declara a versão de cada pacote uma vez, e os projetos citam o pacote sem versão: + + ```xml + + + + + + ``` + + - **Auditoria.** Com `NuGetAudit` no modo `all`, a restauração dos pacotes avisa quando um deles tem uma vulnerabilidade conhecida, mesmo que seja dependência de outro pacote. Como um aviso é um erro (decisão 29), o build quebra. + - **Dependabot.** Um serviço do GitHub que abre, toda semana, pull requests com as atualizações de pacotes, agrupadas por família. +2. **Que problema resolve.** Dois projetos nunca usam versões diferentes do mesmo pacote, e uma vulnerabilidade não passa em silêncio. +3. **O que acontece sem isso.** As versões ficariam espalhadas em dez arquivos `.csproj` e divergiriam com o tempo. +4. **Quanto custa.** Uma vulnerabilidade recém-publicada pode quebrar o build de um dia para o outro, sem ninguém ter mexido no código. E os pull requests do Dependabot precisam de revisão. +5. **Alternativas mais simples.** A versão em cada `.csproj`, atualizada à mão. +6. **Por que escolhemos assim.** É uma opção do próprio .NET, sem pacote extra. Regra do projeto: nenhuma versão é declarada fora desse arquivo. + +**Sobras do plano antigo.** O arquivo tem um grupo de versões declaradas antes do uso. Duas delas são de decisões descartadas ou adiadas e serão removidas: `MailKit` (envio de e-mail) e `Microsoft.Extensions.Caching.Hybrid` (cache de permissões). + +- **Onde ver no código:** [Directory.Packages.props](../backend/ControlService/Directory.Packages.props), [dependabot.yml](../.github/dependabot.yml). +- **Em uma frase:** cada pacote tem a versão declarada num lugar só, e o build quebra se algum tiver uma vulnerabilidade conhecida. + +### 31. .NET Aspire para rodar o sistema na máquina de desenvolvimento + +1. **O que é.** O Aspire é uma ferramenta do .NET para o desenvolvimento local. Um projeto, o `AppHost`, descreve em C# o que o sistema precisa para rodar: + + ```csharp + var postgres = builder.AddPostgres("postgres") + .WithContainerName($"{NamePrefix}-postgres") + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume($"{NamePrefix}-postgres-data"); + + var database = postgres.AddDatabase("controlservice"); + ``` + + Um comando (`dotnet run --project src/ControlService.AppHost`) sobe o PostgreSQL num contêiner, entrega a *connection string* à API, inicia a API e abre um painel com os logs e os tempos de cada requisição. Um segundo projeto, o `ServiceDefaults`, é o modelo do Aspire para a telemetria e os *health checks* (decisão 32). +2. **Que problema resolve.** Quem clona o repositório roda o sistema inteiro só com o Docker e o SDK do .NET, sem instalar o PostgreSQL nem editar uma connection string. +3. **O que acontece sem isso.** Um `docker-compose.yml`, mais a connection string copiada à mão para a configuração. +4. **Quanto custa.** Dois projetos a mais na solução, e uma ferramenta nova, que muda rápido. Serve só ao desenvolvimento: a publicação usa a imagem da decisão 33. +5. **Alternativas mais simples.** O Docker Compose, que é mais conhecido. Ou o PostgreSQL instalado na máquina. +6. **Por que escolhemos assim.** Já está construído, e o painel entrega a observabilidade local sem nenhuma configuração. + +**Sobras do plano antigo.** O `AppHost` ainda sobe o Mailpit, e a API espera por ele, embora nada envie e-mail. E o `ServiceDefaults` liga duas peças do modelo que só agem quando a API chama outro serviço por HTTP, o que ela não faz: a descoberta de serviços (achar o endereço de outro serviço pelo nome) e a resiliência (tentar de novo quando a chamada falha). As três serão removidas. + +- **Onde ver no código:** [AppHost.cs](../backend/ControlService/src/ControlService.AppHost/AppHost.cs), [Extensions.cs](../backend/ControlService/src/ControlService.ServiceDefaults/Extensions.cs), e a linha `builder.AddServiceDefaults()` do [Program.cs](../backend/ControlService/src/ControlService.API/Program.cs). +- **Em uma frase:** um comando sobe o banco e a API já ligados um ao outro, e abre um painel para ver o que acontece. + +### 32. Observabilidade com o `ILogger` do .NET e o OpenTelemetry + +1. **O que é.** Observabilidade é conseguir ver o que o sistema fez. São três peças: + - **Logs:** o `ILogger`, que já vem no .NET, sem biblioteca extra. + - **Traces e métricas:** o OpenTelemetry, um padrão aberto que não prende o código a um fornecedor. Um *trace* é o caminho de uma requisição, com o tempo de cada etapa e de cada consulta ao banco. + - **Destino:** na máquina local, o painel do Aspire. O envio só é ligado quando existe um endereço configurado: + + ```csharp + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + ``` + + Um teste (`AuthLoggingTests`) entra no sistema, renova a sessão e troca a senha, e confere que nenhuma senha, token ou cookie foi escrito nos logs. + + **Não confundir com a auditoria.** "Quem criou" e "quem alterou" cada registro são dados do sistema, gravados no banco (decisão 14). O log é um texto técnico, para quem desenvolve investigar um problema, e é descartado com o tempo. +2. **Que problema resolve.** Quando algo falha, dá para achar a requisição e ver em que etapa ela parou. +3. **O que acontece sem isso.** Só o console, sem ligação entre uma requisição e as consultas que ela fez. +4. **Quanto custa.** Cinco pacotes do OpenTelemetry, todos dentro do `ServiceDefaults`. +5. **Alternativas mais simples.** Só o `ILogger`, escrevendo no console. +6. **Por que escolhemos assim.** O `ILogger` já grava logs estruturados, e o OpenTelemetry já os leva ao painel. O Serilog, uma biblioteca de logs muito usada, estava no plano e foi descartado em 2026-10-05: seria uma dependência a mais para o mesmo resultado. + +**O que falta em relação ao plano antigo,** e fica para quando o sistema for publicado ([Antes de ir para o mundo real](#antes-de-ir-para-o-mundo-real)): +- Não há uma linha de log por requisição com o id de quem a fez. +- Os *health checks* (endereços que respondem se a API está no ar e se alcança o banco) são `/health` e `/alive`, e só existem em desenvolvimento. +- Fora da máquina local, a telemetria não tem destino. + +- **Onde ver no código:** [Extensions.cs](../backend/ControlService/src/ControlService.ServiceDefaults/Extensions.cs), [AuthLoggingTests.cs](../backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AuthLoggingTests.cs). +- **Em uma frase:** uso o log que já vem no .NET e um padrão aberto para os traces, e um teste garante que senha e token nunca vão para o log. + +### 33. Imagem de contêiner por Dockerfile + +1. **O que é.** Uma imagem de contêiner é um pacote com a API e tudo de que ela precisa para rodar. Quem a descreve é o `Dockerfile`, em etapas: uma compila, usando a imagem do SDK; a final tem só o necessário para executar, e roda a API com um usuário sem privilégios. + + ```dockerfile + FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base + USER $APP_UID + ``` + +2. **Que problema resolve.** A API roda do mesmo jeito em qualquer hospedagem que aceite contêineres. +3. **O que acontece sem isso.** Publicar exigiria instalar o .NET no servidor e copiar os arquivos. +4. **Quanto custa.** Um arquivo para manter: cada projeto novo de que a API dependa precisa de uma linha `COPY`. Hoje a imagem não é usada em lugar nenhum; o CI só confere que ela é montada (decisão 34). +5. **Alternativas mais simples.** Deixar o SDK do .NET gerar a imagem, sem Dockerfile (`dotnet publish /t:PublishContainer`), que era o plano original. Ou não ter imagem até existir onde publicá-la. +6. **Por que escolhemos assim.** O Dockerfile já existe, o CI o valida, e é o formato que qualquer pessoa da área reconhece. + +**Sobra.** O projeto da API referencia `Microsoft.VisualStudio.Azure.Containers.Tools.Targets`, numa versão *preview*. Ele só serve para depurar dentro de um contêiner pelo Visual Studio, o que o projeto não usa, e será removido. O Dockerfile não depende dele. + +- **Onde ver no código:** [Dockerfile](../backend/ControlService/src/ControlService.API/Dockerfile), [.dockerignore](../backend/ControlService/.dockerignore). +- **Em uma frase:** a API vira uma imagem que roda igual em qualquer lugar, com um usuário sem privilégios. + +### 34. Integração contínua no GitHub Actions; a publicação é manual + +1. **O que é.** A integração contínua (CI) roda no GitHub a cada pull request e a cada mudança na `main`, em dois trabalhos: + - **Build and test:** restaura os pacotes, compila em `Release` (um aviso é um erro) e roda todos os testes, com o PostgreSQL de verdade. A cobertura de código é guardada como anexo da execução. + - **Build API image:** monta a imagem do Dockerfile, sem publicá-la. + + A `main` é protegida por uma regra do repositório: só recebe mudanças por pull request, e só com os dois trabalhos verdes. O fluxo usa um token só de leitura: + + ```yaml + permissions: + contents: read + ``` + +2. **Que problema resolve.** "Na minha máquina funciona" deixa de valer como prova. O selo verde no README é a evidência. +3. **O que acontece sem isso.** Um pull request poderia quebrar o build ou um teste sem ninguém perceber. +4. **Quanto custa.** Alguns minutos por pull request. A cobertura é coletada, mas não tem meta nem selo. +5. **Alternativas mais simples.** Rodar o `check.ps1` à mão antes de cada merge. +6. **Por que escolhemos assim.** É gratuito para um repositório público e fica à vista de quem avalia o portfólio. Ficaram de fora, em 2026-10-05, a publicação automática e a conferência de um `openapi.json`. + +**A demonstração pública.** O sistema terá uma demonstração aberta a visitantes (decisão 21), publicada à mão. Onde hospedar ainda não foi decidido: a escolha será feita quando o front-end entrar no repositório, porque depende dele. O que falta para publicar está em [Antes de ir para o mundo real](#antes-de-ir-para-o-mundo-real). + +- **Onde ver no código:** [ci.yml](../.github/workflows/ci.yml). +- **Em uma frase:** todo pull request é compilado e testado num ambiente limpo antes de poder entrar na `main`. + ## Antes de ir para o mundo real O sistema é, por enquanto, um portfólio: precisa ser fácil de visitar e ter o ciclo testável do início ao fim. O que está abaixo é aceito nesta fase e precisa ser revisto antes de um uso real. @@ -576,6 +839,12 @@ O sistema é, por enquanto, um portfólio: precisa ser fácil de visitar e ter o | O login `admin` e a senha `admin123` são fixos e ficam à mostra na tela de entrada, e o Admin não troca a senha (decisão 21) | Tirar a senha fixa do código e da tela, e permitir que o Admin troque a própria senha. O Admin e o perfil Gerenciador continuam: são a garantia de que sempre existe alguém com todas as permissões | | Não existe "Esqueci minha senha" | Links de ativação e de redefinição por e-mail | | A senha temporária é passada por fora do sistema | O mesmo link de ativação | +| As migrations só são aplicadas sozinhas em desenvolvimento (decisão 11) | Um passo da publicação que aplique as migrations antes de a versão nova subir | +| A imagem da API é montada no CI, mas não é publicada em lugar nenhum (decisões 33 e 34) | Escolher a hospedagem do front-end, da API e do banco, e publicar | +| Os health checks só existem em desenvolvimento, e a telemetria só vai para o painel local (decisão 32) | Expor os health checks à hospedagem e definir um destino para os logs e os traces | +| Não há log de quem fez cada requisição (decisão 32) | Uma linha de log por requisição com o id do usuário, sem dados pessoais | +| O refresh token viaja num cookie, e o front-end e a API ainda não têm endereço público (decisão 20) | Definir como os dois ficam sob o mesmo endereço, para o navegador aceitar o cookie | +| A conta Admin é pública, e um visitante pode alterar ou apagar dados (decisão 21) | Na demonstração, restaurar o banco de tempos em tempos | ## O que ficou de fora @@ -598,3 +867,11 @@ Avaliado e não adotado. Fica registrado para ninguém propor de novo sem um mot | Autenticação só por cookie | Serviria para este front-end, mas o JWT é o mais pedido em vagas e serve para um aplicativo de celular | | Provedor de identidade externo (Keycloak, Auth0) | Tiraria do código a parte de login, que é uma das que o portfólio quer mostrar | | `SignInManager` e `MapIdentityApi` do Identity | Trazem autenticação por cookie e rotas prontas que não emitem o JWT do projeto | +| Banco em memória ou SQLite nos testes | Não aplicam índices nem restrições como o PostgreSQL; um teste passaria sem a regra existir no banco (decisão 26) | +| Biblioteca de mocks (NSubstitute, Moq) | Os fakes em memória conferem o resultado, e não as chamadas internas, e sobrevivem às refatorações (decisão 27) | +| Regra de nomes nos testes de arquitetura (`...Handler`, `...Validator`) | Um nome fora do padrão não causa defeito, e a revisão o pega (decisão 28) | +| Meta mínima de cobertura de código | A cobertura é coletada no CI; uma meta levaria a escrever testes para o número, e não para as regras | +| Docker Compose | O Aspire já sobe o banco e a API com um comando, e entrega a connection string (decisão 31) | +| Serilog | O `ILogger` do .NET já grava logs estruturados, e o OpenTelemetry os leva ao painel (decisão 32) | +| Gerar a imagem pelo SDK, sem Dockerfile | O Dockerfile já existe, é validado no CI e é o formato mais conhecido (decisão 33) | +| Publicação automática (deploy) | A publicação será manual até o ciclo do produto estar fechado (decisão 34) | From 7e7ae17a5ae72e44230518507a4019aa5c56988b Mon Sep 17 00:00:00 2001 From: Matheus Mendes <104966152+MendesMat@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:30:26 -0300 Subject: [PATCH 7/8] docs: replace the ADRs with the decisions document Remove docs/adr (33 ADRs, their index and template); the decisions now live in docs/decisoes-de-arquitetura.md and stay in the git history. Point AGENTS.md, the READMEs, CONTRIBUTING, the docs index, the agent guides, the levantar-issue skill and the feature template at the new document. Rewrite record-a-decision.md as a short procedure for it. Fix the testing guide: no Mailpit, no mock library, and account-flow cases that match the temporary-password design instead of activation links. Align the architecture guide and the environment guide with the scope decisions of 2026-10-05 (no e-mail, no permission cache). --- .claude/skills/levantar-issue/SKILL.md | 2 +- AGENTS.md | 8 +- CONTRIBUTING.md | 6 +- README.md | 8 +- backend/ControlService/README.md | 4 +- docs/README.md | 4 +- .../adr/0001-record-architecture-decisions.md | 33 ------ docs/adr/0002-use-dotnet-10-lts.md | 36 ------ ...003-use-minimal-apis-grouped-by-feature.md | 35 ------ .../0004-use-built-in-openapi-with-scalar.md | 36 ------ docs/adr/0005-adopt-clean-architecture.md | 40 ------- .../0006-apply-tactical-ddd-in-the-domain.md | 51 --------- .../0007-implement-cqrs-without-mediatr.md | 38 ------- ...08-validate-input-with-fluentvalidation.md | 35 ------ ...-use-result-pattern-and-problem-details.md | 72 ------------ .../0010-map-objects-without-automapper.md | 29 ----- docs/adr/0011-use-postgresql.md | 36 ------ docs/adr/0012-use-uuid-v7-identifiers.md | 30 ----- ...-core-for-writes-and-dapper-for-reports.md | 30 ----- docs/adr/0014-use-optimistic-concurrency.md | 29 ----- .../0015-record-audit-fields-automatically.md | 30 ----- ...16-deactivate-users-instead-of-deleting.md | 33 ------ .../0017-paginate-and-search-on-the-server.md | 31 ------ ...0018-store-signatures-in-object-storage.md | 31 ------ ...0019-authenticate-with-identity-and-jwt.md | 65 ----------- ...thorize-per-screen-with-custom-policies.md | 58 ---------- ...-define-screen-keys-as-stable-constants.md | 42 ------- .../0022-seed-and-protect-system-records.md | 31 ------ ...023-rate-limit-authentication-endpoints.md | 31 ------ docs/adr/0024-testing-strategy.md | 46 -------- ...5-enforce-architecture-rules-with-tests.md | 32 ------ .../0026-enforce-build-quality-settings.md | 36 ------ ...use-dotnet-aspire-for-local-development.md | 30 ----- ...vability-with-serilog-and-opentelemetry.md | 30 ----- .../0029-containerize-and-automate-ci-cd.md | 37 ------- docs/adr/0030-send-transactional-emails.md | 49 --------- ...1-send-access-emails-within-the-request.md | 43 -------- ...lifetime-and-per-request-account-checks.md | 51 --------- .../0033-develop-test-first-in-pair-mode.md | 58 ---------- docs/adr/README.md | 103 ------------------ docs/adr/template.md | 38 ------- docs/agents/README.md | 2 +- docs/agents/guides/architecture.md | 32 +++--- docs/agents/guides/coding-conventions.md | 8 +- docs/agents/guides/communication.md | 2 +- docs/agents/guides/documentation.md | 6 +- docs/agents/guides/local-environment.md | 15 ++- docs/agents/guides/testing.md | 16 +-- .../agents/workflows/git-and-pull-requests.md | 2 +- docs/agents/workflows/record-a-decision.md | 39 ++----- .../workflows/review-dependency-updates.md | 4 +- docs/decisoes-de-arquitetura.md | 3 +- docs/product/features/template.md | 2 +- 53 files changed, 74 insertions(+), 1524 deletions(-) delete mode 100644 docs/adr/0001-record-architecture-decisions.md delete mode 100644 docs/adr/0002-use-dotnet-10-lts.md delete mode 100644 docs/adr/0003-use-minimal-apis-grouped-by-feature.md delete mode 100644 docs/adr/0004-use-built-in-openapi-with-scalar.md delete mode 100644 docs/adr/0005-adopt-clean-architecture.md delete mode 100644 docs/adr/0006-apply-tactical-ddd-in-the-domain.md delete mode 100644 docs/adr/0007-implement-cqrs-without-mediatr.md delete mode 100644 docs/adr/0008-validate-input-with-fluentvalidation.md delete mode 100644 docs/adr/0009-use-result-pattern-and-problem-details.md delete mode 100644 docs/adr/0010-map-objects-without-automapper.md delete mode 100644 docs/adr/0011-use-postgresql.md delete mode 100644 docs/adr/0012-use-uuid-v7-identifiers.md delete mode 100644 docs/adr/0013-use-ef-core-for-writes-and-dapper-for-reports.md delete mode 100644 docs/adr/0014-use-optimistic-concurrency.md delete mode 100644 docs/adr/0015-record-audit-fields-automatically.md delete mode 100644 docs/adr/0016-deactivate-users-instead-of-deleting.md delete mode 100644 docs/adr/0017-paginate-and-search-on-the-server.md delete mode 100644 docs/adr/0018-store-signatures-in-object-storage.md delete mode 100644 docs/adr/0019-authenticate-with-identity-and-jwt.md delete mode 100644 docs/adr/0020-authorize-per-screen-with-custom-policies.md delete mode 100644 docs/adr/0021-define-screen-keys-as-stable-constants.md delete mode 100644 docs/adr/0022-seed-and-protect-system-records.md delete mode 100644 docs/adr/0023-rate-limit-authentication-endpoints.md delete mode 100644 docs/adr/0024-testing-strategy.md delete mode 100644 docs/adr/0025-enforce-architecture-rules-with-tests.md delete mode 100644 docs/adr/0026-enforce-build-quality-settings.md delete mode 100644 docs/adr/0027-use-dotnet-aspire-for-local-development.md delete mode 100644 docs/adr/0028-observability-with-serilog-and-opentelemetry.md delete mode 100644 docs/adr/0029-containerize-and-automate-ci-cd.md delete mode 100644 docs/adr/0030-send-transactional-emails.md delete mode 100644 docs/adr/0031-send-access-emails-within-the-request.md delete mode 100644 docs/adr/0032-define-session-lifetime-and-per-request-account-checks.md delete mode 100644 docs/adr/0033-develop-test-first-in-pair-mode.md delete mode 100644 docs/adr/README.md delete mode 100644 docs/adr/template.md diff --git a/.claude/skills/levantar-issue/SKILL.md b/.claude/skills/levantar-issue/SKILL.md index fa65d36..0a40c43 100644 --- a/.claude/skills/levantar-issue/SKILL.md +++ b/.claude/skills/levantar-issue/SKILL.md @@ -18,7 +18,7 @@ Talk to the owner in Portuguese. The issue body is written in Portuguese, from t ## Steps 1. **Read the issue** and its comments: `gh issue view --comments`. -2. **Read the documentation it touches:** the feature document in `docs/product/features/`, `docs/product/conventions.md`, `docs/api/conventions.md`, the architecture decisions it cites and `docs/product/open-questions.md`. The scope decisions in `AGENTS.md` override older ADRs and issues. +2. **Read the documentation it touches:** the feature document in `docs/product/features/`, `docs/product/conventions.md`, `docs/api/conventions.md`, the decisions that apply in `docs/decisoes-de-arquitetura.md` and `docs/product/open-questions.md`. The scope decisions in `AGENTS.md` override older issues. 3. **Read the related code:** the files the issue will touch, and the nearest similar feature already built, which is the pattern to follow. 4. **Survey:** - the requirement or the problem, in one sentence; diff --git a/AGENTS.md b/AGENTS.md index 8c578d0..a80cb95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Control Service is an ERP for service companies and the owner's **public portfol - **Product goal, in this order:** sign-in and security → the records the planned screens need → clients and services → the commercial and financial workflow → reports → the whole cycle closed and demonstrable. - **Stack:** .NET 10 / C# 14, ASP.NET Core Minimal APIs, EF Core 10, PostgreSQL 18, .NET Aspire 13.5, xUnit v3 on Microsoft.Testing.Platform. Four projects by layer (Domain, Application, Infrastructure, API) with feature folders. - **Business rules:** `docs/product/` is the source of truth: one document per feature in `docs/product/features/`, plus the rules shared by all in `docs/product/conventions.md`. Rules have stable IDs (`USR-06`, `PERM-05`); cite them in tests and pull requests. User-facing messages are in Portuguese, verbatim. Terms: [domain glossary](docs/product/glossary.md). Start at the [docs index](docs/README.md). -- **Technical decisions:** `docs/adr/`, until one decisions document replaces it. Where an ADR or an older issue disagrees with this file, this file wins. +- **Technical decisions:** [docs/decisoes-de-arquitetura.md](docs/decisoes-de-arquitetura.md), one document in Portuguese that explains each decision in six points. Where an older issue disagrees with this file, this file wins. - **Work items:** GitHub issues, grouped in milestones and numbered in dependency order. Unless the owner names one, take the lowest open issue of the current milestone (`gh issue list --milestone "M1: Sign-in, users and permissions"`). | Path | Content | @@ -18,14 +18,14 @@ Control Service is an ERP for service companies and the owner's **public portfol | `backend/ControlService/tests/` | Domain, Application, API integration and architecture tests | | `docs/product/` | Business rules: overview, glossary, conventions, features, open questions, screen catalog | | `docs/api/` | What every endpoint shares: routes, paging, versions, errors | -| `docs/adr/` | Architecture Decision Records | +| `docs/decisoes-de-arquitetura.md` | Architecture decisions, in Portuguese: what, why, cost and alternatives | | `docs/agents/` | Guides for agents, and the owner's guide | | `.claude/skills/` | The three workflow commands | | `docs/frontend/` | The front-end prototype (not in this repository yet) and its simulated server | ### Scope decisions of 2026-10-05 -Taken by the owner; they override the ADRs and issues written before that date. +Taken by the owner; they override the issues written before that date. - **No permission cache.** The account status and the effective levels are read from the database on each request. - **Access by e-mail is postponed.** Whoever registers a user sets a temporary password, and the person must replace it on first sign-in, through the mechanism the Admin already uses. No SMTP, activation links or reset links until the product cycle is closed. @@ -172,6 +172,6 @@ If a request conflicts with `docs/`, point out the conflict and ask before actin | [Documentation](docs/agents/guides/documentation.md) | Behavior, contract or decisions change | | [Local environment](docs/agents/guides/local-environment.md) | Running the system, Docker, HTTPS, PowerShell | | [Communication](docs/agents/guides/communication.md) | Asking the owner a question or reporting a result | -| [Record a decision](docs/agents/workflows/record-a-decision.md) | Changing an ADR, while the ADRs exist | +| [Record a decision](docs/agents/workflows/record-a-decision.md) | Adding or changing an architecture decision | | [Git and pull requests](docs/agents/workflows/git-and-pull-requests.md) | Delivering any change | | [Review dependency updates](docs/agents/workflows/review-dependency-updates.md) | Handling Dependabot pull requests | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9482f4..4d56ce7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ ``` feat(users): validate CPF check digits fix(auth): reset lockout after password reset - docs(adr): accept ADR-0018 + docs: record the decision on signature storage ``` 3. Run the tests before pushing: @@ -44,6 +44,6 @@ - Code, identifiers, commits and documentation in English; user-facing messages in Portuguese, verbatim from `docs/product/`. - Business rules have stable IDs (`USR-06`, `PERM-05`): cite them in tests and pull requests ([docs index](docs/README.md#rule-ids)). -- Development is test-driven: every behavior starts as a failing test, and each issue carries a numbered test list ([AGENTS.md](AGENTS.md#tests)). Business rules live in the Domain project (ADR-0005). +- Development is test-driven: every behavior starts as a failing test, and each issue carries a numbered test list ([AGENTS.md](AGENTS.md#tests)). Business rules live in the Domain project. - Package versions are declared only in `Directory.Packages.props`. -- A significant technical decision gets an ADR in `docs/adr/`. +- A significant technical decision is recorded in the [decisions document](docs/decisoes-de-arquitetura.md), which is in Portuguese ([how](docs/agents/workflows/record-a-decision.md)). diff --git a/README.md b/README.md index 9e8456d..e18bcdd 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ An ERP for service companies: users and permissions, service catalog, clients an - **Invitation-based accounts.** Nobody signs up alone: a new user receives a single-use activation link by e-mail and creates their own password. Nobody ever sees or types someone else's password. - **Safe concurrent editing.** Optimistic concurrency rejects a save when someone else changed the record in the meantime, and tells who did it. - **Traceability.** Every record stores who created it, who last changed it and when. Users are deactivated, never deleted. -- **Test-driven development.** Every behavior starts as a failing test, in every layer, and business rules carry stable IDs that the tests cite ([ADR-0033](docs/adr/0033-develop-test-first-in-pair-mode.md)). +- **Test-driven development.** Every behavior starts as a failing test, in every layer, and business rules carry stable IDs that the tests cite ([decision 27](docs/decisoes-de-arquitetura.md)). - **Architecture enforced by tests.** Clean Architecture layers are separate projects, and architecture tests fail the build if a layer depends on the wrong one. -- **Decisions on record.** Every significant technical choice has an [Architecture Decision Record](docs/adr/README.md) with the alternatives considered. +- **Decisions on record.** Every significant technical choice is explained in the [decisions document](docs/decisoes-de-arquitetura.md), with what it costs and the alternatives considered. - **AI-assisted, human-reviewed.** AI coding agents implement changes following [AGENTS.md](AGENTS.md): they work on branches and open pull requests, and the owner reviews and merges every one of them. ## Tech stack @@ -45,7 +45,7 @@ backend/ControlService/src └── ControlService.ServiceDefaults Telemetry, health checks and resilience ``` -Dependencies point inward only: `API → Application → Domain`, with `Infrastructure` implementing the Application interfaces. Inside each project, code is grouped by feature (`Users`, `PermissionProfiles`, `Auth`). See [ADR-0005](docs/adr/0005-adopt-clean-architecture.md) and the [back-end README](backend/ControlService/README.md). +Dependencies point inward only: `API → Application → Domain`, with `Infrastructure` implementing the Application interfaces. Inside each project, code is grouped by feature (`Users`, `PermissionProfiles`, `Auth`). See the [decisions document](docs/decisoes-de-arquitetura.md) and the [back-end README](backend/ControlService/README.md). ## Getting started @@ -77,7 +77,7 @@ dotnet test --solution ControlService.slnx | Document | Language | Content | |---|---|---| | [Product documentation](docs/README.md) | English | Product overview, glossary, business rules by feature (with stable rule IDs), API conventions and open questions | -| [Architecture Decision Records](docs/adr/README.md) | English | What was decided for the back-end, why, and which alternatives were rejected | +| [Architecture decisions](docs/decisoes-de-arquitetura.md) | Portuguese | How the back-end is built, why, what each choice costs and which alternatives were rejected | | [Back-end guide](backend/ControlService/README.md) | Portuguese | How to run, project structure and what each Docker resource is | | [AGENTS.md](AGENTS.md) and [agent guides](docs/agents/README.md) | English | Rules, guides and workflows for the AI coding agents that work on this repository | | [Trabalhando com agentes](docs/agents/trabalhando-com-agentes.md) | Portuguese | How the owner pairs with AI agents: asking for tasks, reviewing pull requests | diff --git a/backend/ControlService/README.md b/backend/ControlService/README.md index a17b004..dc69fea 100644 --- a/backend/ControlService/README.md +++ b/backend/ControlService/README.md @@ -1,6 +1,6 @@ # Control Service: back-end -API em C# com .NET 10, organizada em Clean Architecture ([ADR-0005](../../docs/adr/0005-adopt-clean-architecture.md)) com Minimal APIs ([ADR-0003](../../docs/adr/0003-use-minimal-apis-grouped-by-feature.md)). As regras de negócio estão em [`docs/`](../../docs/README.md). +API em C# com .NET 10, organizada em Clean Architecture com Minimal APIs (as decisões estão em [`docs/decisoes-de-arquitetura.md`](../../docs/decisoes-de-arquitetura.md)). As regras de negócio estão em [`docs/`](../../docs/README.md). ## Como rodar @@ -105,7 +105,7 @@ Pastas previstas para a primeira etapa: `Access` (níveis, chaves das telas, ace ## Configurações comuns -- `Directory.Build.props`: .NET 10, nullable, avisos tratados como erros e analisadores ([ADR-0026](../../docs/adr/0026-enforce-build-quality-settings.md)). +- `Directory.Build.props`: .NET 10, nullable, avisos tratados como erros e analisadores (decisão 29 em [`docs/decisoes-de-arquitetura.md`](../../docs/decisoes-de-arquitetura.md)). - `Directory.Packages.props`: **todas as versões de pacotes ficam aqui**. No `.csproj`, use `` sem versão. - `tests/Directory.Build.props`: xUnit v3 e Shouldly para todos os projetos de teste. diff --git a/docs/README.md b/docs/README.md index fd25177..2e20d5b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ Everything about Control Service that is not code: what the system does, how the | Understand the product | [product/overview.md](product/overview.md), then [product/glossary.md](product/glossary.md) | | Implement or change a feature | Its file in [product/features/](product/features/), plus [product/conventions.md](product/conventions.md) and [api/conventions.md](api/conventions.md) | | Add a new screen | [product/features/template.md](product/features/template.md), then the `/levantar-issue` command ([agents/](agents/README.md#the-three-commands)) | -| Know why the back-end is built this way | [adr/](adr/README.md) | +| Know why the back-end is built this way | [decisoes-de-arquitetura.md](decisoes-de-arquitetura.md) (Portuguese) | | Check what is still undecided | [product/open-questions.md](product/open-questions.md) | | Work on the front-end prototype | [frontend/](frontend/README.md) | | Work as an AI agent | [AGENTS.md](../AGENTS.md), then [agents/](agents/README.md) | @@ -33,7 +33,7 @@ docs/ │ └── permission-profiles.md (PERM) ├── api/ │ └── conventions.md What every endpoint shares: routes, paging, versions, errors (API) -├── adr/ Architecture Decision Records, numbered, with metadata +├── decisoes-de-arquitetura.md How the back-end is built and why: the architecture decisions (Portuguese) ├── frontend/ The front-end prototype and its simulated server └── agents/ Rules, guides and workflows for AI coding agents ``` diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md deleted file mode 100644 index 8735e4e..0000000 --- a/docs/adr/0001-record-architecture-decisions.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -status: proposed -date: 2026-09-23 -scope: back-end -tags: [process] ---- - -# ADR-0001: Record architecture decisions - -## Context - -Control Service is a portfolio ERP. The front-end already exists as a single HTML file, and the back-end will be built in C# with .NET. Many technical choices will be made before and during that work, and the reasons behind them are easy to lose. Recruiters and interviewers who read the repository also benefit from seeing why each choice was made, not only what was chosen. - -## Decision - -We will record every significant architectural decision as an Architecture Decision Record (ADR) in `docs/adr/`, written in English. - -Each ADR is a Markdown file named `NNNN-short-title.md` and follows the structure in [template.md](template.md): YAML metadata (status, dates, tags and relations to other records, described in the [index](README.md#metadata)), context, decision, alternatives considered and consequences. An ADR covers one decision. All records stay in one folder, ordered by number. - -An ADR starts as **Proposed**. It becomes **Accepted** when the decision is implemented or explicitly agreed. Accepted ADRs are not rewritten. If a decision changes, a new ADR is written and the old one is marked **Superseded by ADR-NNNN**. - -The product documentation in `docs/product/` describes *what* the system does. ADRs describe *how* the back-end is built and *why*. - -## Alternatives considered - -- **A single architecture document.** Easier to start, but it tends to be rewritten in place, which erases the history of why things changed. -- **No written record.** Decisions would live only in commit messages and memory. - -## Consequences - -- Each decision has a stable place to be discussed, reviewed and referenced from code and pull requests. -- Writing an ADR adds a small cost to every significant decision. -- The index in [README.md](README.md) must be kept up to date when ADRs are added or superseded. diff --git a/docs/adr/0002-use-dotnet-10-lts.md b/docs/adr/0002-use-dotnet-10-lts.md deleted file mode 100644 index f4c99a7..0000000 --- a/docs/adr/0002-use-dotnet-10-lts.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-23 -scope: back-end -tags: [platform] ---- - -# ADR-0002: Use .NET 10 LTS and C# 14 - -## Context - -The back-end will be written in C#. Microsoft ships a new .NET version every November; even-numbered versions are Long Term Support (LTS) releases with three years of support. - -.NET 10 was released in November 2025 as an LTS release, supported until November 2028. .NET 8 (LTS) and .NET 9 (STS) both reach end of support on November 10, 2026. A project started today on .NET 8 or 9 would be out of support within weeks. - -## Decision - -We will target **.NET 10** (`net10.0`), **C# 14** and **ASP.NET Core 10** for every project in the solution. - -The SDK version will be pinned in `global.json`, with `rollForward` set to `latestFeature`, so every machine and the CI pipeline build with the same major SDK. - -## Alternatives considered - -- **.NET 8 LTS.** Widely used in existing systems, but its support ends on November 10, 2026. -- **.NET 9 STS.** Same end-of-support date as .NET 8, with no advantage over .NET 10. - -## Consequences - -- The project runs on a supported runtime until November 2028, with no forced migration during the portfolio's useful life. -- Some third-party packages may lag behind .NET 10; each dependency must be checked for `net10.0` compatibility before it is added. -- A migration to the next LTS (.NET 12, expected November 2027) should be planned before November 2028. - -## References - -- [.NET and .NET Core Support Policy](https://dotnet.microsoft.com/platform/support-policy/dotnet-core) diff --git a/docs/adr/0003-use-minimal-apis-grouped-by-feature.md b/docs/adr/0003-use-minimal-apis-grouped-by-feature.md deleted file mode 100644 index 89c7aa1..0000000 --- a/docs/adr/0003-use-minimal-apis-grouped-by-feature.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [platform, api] -superseded-by: ADR-0008 (validation through endpoint filters only) ---- - -# ADR-0003: Use Minimal APIs grouped by feature - -## Context - -ASP.NET Core offers two ways to expose HTTP endpoints: MVC Controllers and Minimal APIs. Controllers are still common in existing enterprise systems. Minimal APIs are the default in current project templates and have received most of the recent investment (endpoint filters, typed results, native OpenAPI support). - -The front-end is organized by screens (Usuários, Permissões, and 18 more to come). Each screen maps naturally to one back-end feature. - -## Decision - -We will use **Minimal APIs**, organized **by feature**. Each feature (for example `Users` or `PermissionProfiles`) lives in its own folder in the API project and registers its endpoints through a route group created with `MapGroup`. - -- All routes share the `/api/v1` prefix. The version segment exists from day one so a future breaking change can live under `/api/v2`. -- Endpoints return **typed results** (`Results, NotFound, ValidationProblem>`), which keeps responses explicit and feeds the OpenAPI document. -- Cross-cutting behavior (validation, authorization) is applied per group through **endpoint filters** and metadata, not repeated in each endpoint. - -## Alternatives considered - -- **MVC Controllers.** Familiar and well documented, and worth knowing for existing codebases. They add attributes and base classes that Minimal APIs no longer need. -- **A third-party endpoint library (for example FastEndpoints or Carter).** Adds structure, but also a dependency that the built-in `MapGroup` already covers. - -## Consequences - -- The API code mirrors the screens of the front-end, which makes it easy to find where a behavior lives. -- The project demonstrates the current ASP.NET Core style; the team should still be able to explain how the same thing is done with Controllers. -- A small convention (an extension method per feature, such as `MapUserEndpoints`) is needed to keep `Program.cs` short. diff --git a/docs/adr/0004-use-built-in-openapi-with-scalar.md b/docs/adr/0004-use-built-in-openapi-with-scalar.md deleted file mode 100644 index 0a70e7b..0000000 --- a/docs/adr/0004-use-built-in-openapi-with-scalar.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-23 -scope: back-end -tags: [platform, api] ---- - -# ADR-0004: Use built-in OpenAPI generation with the Scalar UI - -## Context - -The API needs a machine-readable contract and an interactive page to explore it. For years this was done with Swashbuckle. Starting with .NET 9, the ASP.NET Core templates no longer include Swashbuckle; OpenAPI documents are generated by the first-party `Microsoft.AspNetCore.OpenApi` package instead. - -## Decision - -We will generate the OpenAPI document with **`Microsoft.AspNetCore.OpenApi`** (`AddOpenApi` and `MapOpenApi`) and expose an interactive reference with **Scalar** (`Scalar.AspNetCore`, `MapScalarApiReference`). - -- The interactive UI is enabled in the Development environment and in the public demo environment. -- The OpenAPI document is also generated at build time (`Microsoft.Extensions.ApiDescription.Server`) and committed under `docs/api/openapi.json`, so the contract can be reviewed in pull requests. -- Endpoint summaries, descriptions and examples are written in English; user-facing error messages stay in Portuguese (see ADR-0009). - -## Alternatives considered - -- **Swashbuckle.** Still works, but it is no longer part of the default templates. -- **NSwag.** Mature and able to generate clients, but heavier than needed. - -## Consequences - -- The contract is generated from the endpoints themselves, so it cannot drift from the code. -- Changes to the contract become visible in code review through the committed `openapi.json`. -- Scalar is a third-party UI; replacing it later does not affect the generated document. - -## References - -- [ASP.NET Core OpenAPI documentation](https://aka.ms/aspnet/openapi) diff --git a/docs/adr/0005-adopt-clean-architecture.md b/docs/adr/0005-adopt-clean-architecture.md deleted file mode 100644 index 7a3802e..0000000 --- a/docs/adr/0005-adopt-clean-architecture.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [architecture] ---- - -# ADR-0005: Adopt Clean Architecture with four projects - -## Context - -The ERP will grow from two screens to more than twenty, with business rules that must not depend on the web framework or the database. Clean Architecture is also one of the most requested architectural styles in .NET job descriptions, which makes it a good fit for a portfolio. - -## Decision - -The solution will have four production projects, with dependencies pointing inward only: - -| Project | Responsibility | May reference | -|---|---|---| -| `ControlService.Domain` | Entities, value objects, domain rules and domain errors. No framework dependencies. | Nothing | -| `ControlService.Application` | Use cases (commands and queries), validation, interfaces for infrastructure (repositories, storage, current user). | Domain | -| `ControlService.Infrastructure` | EF Core, Dapper, object storage, identity, caching: implementations of Application interfaces. | Application, Domain | -| `ControlService.API` | HTTP endpoints, authentication setup, composition root. | Application, Infrastructure (for DI registration only) | - -Test projects mirror these (`ControlService.Domain.Tests`, `ControlService.Application.Tests`, `ControlService.Api.IntegrationTests`, `ControlService.ArchitectureTests`). Local orchestration adds the Aspire projects described in ADR-0027. - -Inside every project, code is organized **by feature** (`Access`, `Auth`, `Users`, `PermissionProfiles`, `Screens`), not by technical type. The folder names tell what the system does, while the project boundaries enforce the dependency rule. - -## Alternatives considered - -- **Vertical Slice Architecture in a single project.** Less ceremony and increasingly popular; it would be a valid choice. Clean Architecture was preferred because the layering itself is part of what the portfolio should demonstrate. -- **A single project with folders.** Simplest, but nothing prevents the domain from depending on EF Core or ASP.NET Core. -- **One project per menu area (Management, Commercial, Finance, Reporting).** Tried first. It puts walls between business areas instead of between layers, so nothing stops a domain class from using EF Core. The areas also depend on each other constantly (Finance needs Commercial clients, Reporting reads everything), sign-in and permissions belong to no single area, and the menu grouping is a presentation concern that may change (see ADR-0021). Discarded before any code was written. - -## Consequences - -- Business rules can be tested without a database or web server. -- Adding a feature touches several projects; the by-feature folders keep related files easy to find. -- The dependency rules are enforced automatically by architecture tests (ADR-0025). diff --git a/docs/adr/0006-apply-tactical-ddd-in-the-domain.md b/docs/adr/0006-apply-tactical-ddd-in-the-domain.md deleted file mode 100644 index 7232d2f..0000000 --- a/docs/adr/0006-apply-tactical-ddd-in-the-domain.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [architecture, domain] ---- - -# ADR-0006: Apply tactical DDD in the domain - -## Context - -The front-end currently validates CPF, phone and CEP and stores them **with formatting masks** (`529.982.247-25`). Every rule lives only in the browser. The permission model relies on an ordered set of access levels, and the effective level of a user is the highest level among their profiles. These rules belong to the business, not to any particular screen. See `docs/product/conventions.md`, `docs/product/features/users.md` and `docs/product/features/permission-profiles.md`. - -## Decision - -The Domain project will use tactical Domain-Driven Design patterns. - -**Aggregates.** `User` and `PermissionProfile` are aggregate roots. Each exposes behavior methods (for example `User.AssignProfiles`, `PermissionProfile.SetLevel`) instead of public setters, and protects its own invariants, such as: a system record cannot be changed. A user **may have no profiles at all**; this is a valid state that means `Denied` on every screen, and the project owner explicitly confirmed it. - -**Value objects.** Immutable types with validation in their factory methods: - -| Value object | Stored as | Rule | -|---|---|---| -| `Cpf` | 11 digits | Valid check digits; all-equal sequences rejected. Informational only and **not unique**. | -| `Login` | Lowercase text | 3 to 30 characters: unaccented letters, digits, `.`, `-`, `_`. Unique (checked by the application and the database). | -| `EmailAddress` | Text | Valid e-mail format. Not unique. | -| `PhoneNumber` | 10 or 11 digits | Must include area code. | -| `Cep` | 8 digits | Exactly 8 digits. | -| `BloodType` | One of eight values | Closed list. | -| `ScreenKey` | Text | Must exist in the screen catalog (ADR-0021). | -| `AccessLevel` | Ordered enumeration | `Denied < Reader < Editor < Manager`. | - -**Normalized storage.** CPF, phone and CEP are **informational data**: no other rule depends on them. They are stored and returned as **digits only**. The API accepts both masked and unmasked input. Formatting is a presentation concern handled by the front-end. This was confirmed by the project owner. - -**Wire format of access levels.** In C#, `AccessLevel` uses English names. On the wire and in the database it keeps the existing identifiers `negado`, `leitor`, `editor` and `gerenciador`, so the current front-end contract does not change. - -**Effective access.** A domain service computes the effective level of a user for a screen as the **maximum level among the user's profiles**, treating a missing entry as `Denied`. With no profiles, the maximum of an empty set is `Denied`. The calculation is done independently for each screen (each submenu item). `Denied` means "no permission granted by this profile", not an explicit prohibition: it never overrides a higher level granted by another profile. This business rule is described in `docs/product/features/permission-profiles.md`. - -## Alternatives considered - -- **Anemic entities with validation in services.** Simpler at first, but rules end up duplicated across handlers. -- **Keep masked values in storage.** Matches the current front-end, but makes uniqueness checks and searches depend on formatting. -- **Most restrictive level wins, or explicit deny overrides.** Common in security products, where a single deny must always block. Rejected for this ERP: profiles describe job roles, and a person who accumulates roles is expected to accumulate access. Restricting someone is done by removing the profile that grants access. - -## Consequences - -- The rules in `docs/product/` become unit-testable domain code, independent of the front-end. -- The front-end adapter must format CPF, phone and CEP for display and send them unmasked or masked; either is accepted. -- EF Core needs value converters for the value objects. -- Adding a profile to a user can only increase their access; reducing access requires removing a profile or lowering a level inside one. The user interface must make this clear when profiles are assigned. diff --git a/docs/adr/0007-implement-cqrs-without-mediatr.md b/docs/adr/0007-implement-cqrs-without-mediatr.md deleted file mode 100644 index d5e8a78..0000000 --- a/docs/adr/0007-implement-cqrs-without-mediatr.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [architecture] ---- - -# ADR-0007: Implement CQRS with in-house handlers instead of MediatR - -## Context - -Separating commands (changes) from queries (reads) keeps use cases small and makes read paths easy to optimize. In .NET this was usually done with MediatR. MediatR and AutoMapper launched commercial editions on July 2, 2025: version 12 and earlier keep their open-source licenses, while version 13 onward uses a dual license with a free community tier for individuals and non-commercial use. - -## Decision - -We will implement a lightweight CQRS structure **without MediatR**: - -- The Application project defines `ICommandHandler` and `IQueryHandler`. -- Handlers are registered in dependency injection and injected directly into the endpoints that use them. -- Cross-cutting behavior (validation, logging, unit of work) is added with **decorators** around the handler interfaces, registered with Scrutor or by hand. -- Commands go through the domain model and EF Core. Queries may skip the domain and project straight to response DTOs (`AsNoTracking`, or Dapper for reports; see ADR-0013). - -## Alternatives considered - -- **MediatR 12.** Free, but frozen on an older version with no future updates. -- **MediatR 13+ community license.** Allowed for a personal portfolio, but ties the project to a commercial license model. -- **Another mediator library.** Solves the same problem with a new dependency. - -## Consequences - -- The dispatch mechanism is a few dozen lines of code owned by the project, with no license concerns. -- The portfolio shows an understanding of the pattern rather than just the use of a library. -- "Go to implementation" from an endpoint leads straight to the handler, which keeps navigation simple. - -## References - -- [MediatR repository and licensing](https://github.com/LuckyPennySoftware/MediatR) diff --git a/docs/adr/0008-validate-input-with-fluentvalidation.md b/docs/adr/0008-validate-input-with-fluentvalidation.md deleted file mode 100644 index 339460b..0000000 --- a/docs/adr/0008-validate-input-with-fluentvalidation.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-25 -scope: back-end -tags: [architecture] -supersedes: ADR-0003 (validation through endpoint filters only) ---- - -# ADR-0008: Validate input with FluentValidation - -## Context - -The front-end validates every form before saving (`docs/product/features/`), but the back-end cannot trust the client. Validation errors must reach the front-end in a shape it can show next to each field, with the same Portuguese messages. - -## Decision - -Commands are validated with **FluentValidation** validators in the Application project. - -- A validation decorator (ADR-0007) runs the validator before the handler; failures never reach the domain. -- Validators check shape and format: required fields, lengths, formats. Domain invariants stay in value objects and aggregates (ADR-0006). -- Rules that need the database, such as the uniqueness of the user's login and display name and of the profile name, run in the handler. CPF is validated for format only and is not unique. -- A user's profile list may be empty. Profile ids that do not exist are discarded rather than rejected. Asking for confirmation before saving a user without profiles is a front-end concern, not a validation rule. Unique indexes in the database remain the final guarantee (ADR-0011). -- Error messages are the Portuguese messages already defined in `docs/product/features/`. -- Property names in errors use the same camelCase field names the front-end uses (`fullName`, `cpf`, `emergencyContact.phone`), so each message lands next to the right field. - -## Alternatives considered - -- **Data annotations.** Built in, but limited for conditional rules such as "if filled, must be valid". -- **Validation only in the domain.** Would mix user-input concerns with business invariants. - -## Consequences - -- The front-end can display server errors exactly like its own client-side errors. -- Messages are defined in two places (front-end and validators) until a shared source is introduced. diff --git a/docs/adr/0009-use-result-pattern-and-problem-details.md b/docs/adr/0009-use-result-pattern-and-problem-details.md deleted file mode 100644 index 0fa9b35..0000000 --- a/docs/adr/0009-use-result-pattern-and-problem-details.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [architecture, api] ---- - -# ADR-0009: Use the Result pattern for business errors and Problem Details for HTTP errors - -## Context - -Some failures are expected outcomes, not exceptional situations: a login already taken, a profile still in use, an attempt to change a system record. Using exceptions for them makes control flow hard to follow and hard to test. The HTTP layer also needs a consistent error format. - -## Decision - -- Handlers return a small in-house **`Result` / `Result`** type carrying either a value or an `Error` (a stable `code` and a Portuguese `message`). No external library is used. -- Exceptions are reserved for unexpected failures (database down, bugs). -- The API translates results into **Problem Details** (RFC 9457) with `AddProblemDetails`: - -| Situation | Status | `code` | -|---|---|---| -| Validation failure, **including a login, display name or profile name already in use** | 400 | `validation_failed`, with `errors` by field | -| Not signed in or session expired | 401 | `session_expired` | -| Wrong login or password | 401 | `invalid_credentials` | -| Account deactivated during a session | 401 | `account_inactive` | -| Initial password not changed yet | 401 | `password_change_required` | -| Correct password, but the account is deactivated (sign-in only) | 403 | `account_inactive` | -| Not allowed by permissions | 403 | `forbidden` | -| Record not found | 404 | `not_found` | -| Concurrency conflict | 409 | `concurrency_conflict` | -| Business rule conflict | 409 | `profile_in_use`, `system_record`, `self_deactivation`, `not_pending`, `not_inactive`, `email_missing` | -| Activation or password-reset link expired or already used | 410 | `link_invalid` | -| Lockout after repeated failed sign-ins, or rate limit (ADR-0023) | 429 | `locked_out`, with `Retry-After` | -| Unexpected error | 500 | Generic Portuguese message, details only in logs | - -Duplicates are validation failures, not 409 conflicts, because the front-end shows them under the field, like any other validation message (`docs/api/conventions.md`). The database unique index is still the last line of defense: if two requests race, the index violation is translated into the same 400 response. - -**Response body.** Every error has this shape. `code`, `message` and, when present, `errors` and `details` are Problem Details extensions: - -```json -{ - "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", - "title": "Bad Request", - "status": 400, - "code": "validation_failed", - "message": "Alguns campos precisam ser corrigidos.", - "errors": { - "login": ["Já existe um usuário com o login “ana.souza”. Escolha outro."], - "address.cep": ["O CEP precisa ter 8 números."] - }, - "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" -} -``` - -- `errors` uses the model path of each field in camelCase (`login`, `address.cep`, `emergencyContact.phone`). On the password screens, the fields are `password` and `passwordConfirmation`. -- `details` carries rule-specific data: `updatedByName` for `concurrency_conflict`, `userNames` for `profile_in_use`. -- The front-end's HTTP client converts this body into its `ApiError` (`status`, `code`, `message`, `details.fields`), so the screens do not change (`docs/api/conventions.md`). - -- Every Problem Details response includes the request `traceId`, so a user report can be matched to logs (ADR-0028). - -## Alternatives considered - -- **Exceptions for everything, handled by middleware.** Less code at first, but hides expected outcomes in `catch` blocks. -- **A Result library (for example ErrorOr or FluentResults).** Works well; an in-house type was preferred to keep dependencies minimal. -- **409 Conflict for duplicates.** Semantically common, but the front-end would need a second path to show a field message. - -## Consequences - -- Handler signatures show which failures are possible. -- The front-end must map `code` values to its dialogs, for example "profile in use". -- The error table above becomes part of the API contract and should appear in the OpenAPI document. diff --git a/docs/adr/0010-map-objects-without-automapper.md b/docs/adr/0010-map-objects-without-automapper.md deleted file mode 100644 index f21a2ab..0000000 --- a/docs/adr/0010-map-objects-without-automapper.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-25 -scope: back-end -tags: [architecture] ---- - -# ADR-0010: Map objects manually, with Mapperly as an option - -## Context - -Entities must be converted to response DTOs and requests to commands. AutoMapper was the traditional choice, but it launched a commercial edition in July 2025 alongside MediatR. Reflection-based mapping also hides errors until runtime. - -## Decision - -We will map objects with **hand-written extension methods** (`user.ToResponse()`, `request.ToCommand()`), kept next to the DTOs of each feature. - -If mapping code grows repetitive, **Mapperly** may be introduced. It generates plain mapping code at compile time, so errors surface in the build. - -## Alternatives considered - -- **AutoMapper.** Familiar, but now commercially licensed, and its runtime configuration can fail silently. -- **Mapster.** Capable, with both runtime and code-generation modes; Mapperly's compile-time-only approach was preferred. - -## Consequences - -- Mappings are explicit, easy to debug and friendly to "find all references". -- Adding a field requires updating the mapping by hand, which also forces a conscious decision about exposing it. diff --git a/docs/adr/0011-use-postgresql.md b/docs/adr/0011-use-postgresql.md deleted file mode 100644 index a9a3f25..0000000 --- a/docs/adr/0011-use-postgresql.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [data] ---- - -# ADR-0011: Use PostgreSQL as the primary database - -## Context - -The ERP stores relational data with clear relationships: users, profiles, the profiles of each user, and the level of each profile for each screen. Future screens (clients, accounts payable and receivable) are also relational. SQL Server is very common in .NET jobs; PostgreSQL is free, light to run in Docker and widely used in newer companies. - -## Decision - -We will use **PostgreSQL** (a currently supported major version, 17 or later) through the Npgsql provider for EF Core. - -- Table and column names use `snake_case`, following PostgreSQL conventions. -- **Uniqueness lives in the database** as the last line of defense: - - A unique index on the user's login, stored in lowercase. - - A unique index on a normalized copy of the user's display name. - - A unique index on a normalized copy of the profile name. - - "Normalized" means trimmed, lowercase and without accents, matching the rules in `docs/product/conventions.md`. The normalized column is filled by the application, so the rule is identical in C# and in the database. -- The many-to-many relationship between users and profiles is a join table, not an array column. -- Profile levels are stored as rows (`profile_id`, `screen_key`, `level`), with a missing row meaning `Denied` (see `docs/product/features/permission-profiles.md`). - -## Alternatives considered - -- **SQL Server.** Excellent tooling and very common in .NET jobs; would be equally valid. It requires a heavier container locally and a paid license in production. -- **A document database.** Would match the current JSON records, but the domain is relational. - -## Consequences - -- Local development and CI run the same database engine in containers (ADR-0024, ADR-0027). -- The project can switch to SQL Server later with limited effort because data access goes through EF Core and a small Dapper layer. diff --git a/docs/adr/0012-use-uuid-v7-identifiers.md b/docs/adr/0012-use-uuid-v7-identifiers.md deleted file mode 100644 index fc0d2aa..0000000 --- a/docs/adr/0012-use-uuid-v7-identifiers.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [data] ---- - -# ADR-0012: Use UUID v7 identifiers generated by the server - -## Context - -The front-end currently creates record ids itself (random UUID v4) when a form is opened, and the two system records use readable ids: `sistema-admin` and `sistema-gerenciador`. Random UUIDs spread inserts across the whole index, which hurts write performance as tables grow. Since .NET 9, `Guid.CreateVersion7()` produces time-ordered UUIDs. - -## Decision - -- Primary keys are `Guid` values generated **by the server** with `Guid.CreateVersion7()` when a record is created. -- Creation endpoints return `201 Created` with the new id and a `Location` header. -- System records get fixed, well-known GUIDs defined as constants (ADR-0022). The front-end constants `sistema-admin` and `sistema-gerenciador` are replaced by these GUIDs. - -## Alternatives considered - -- **Keep client-generated ids.** Simple for the current front-end, but lets clients choose keys. -- **Integer identity columns.** Compact, but expose record counts and are easy to guess. -- **UUID v4.** Unique, but random ordering fragments indexes. - -## Consequences - -- The front-end must stop generating ids and read the id from the creation response. Its storage adapter changes from "upsert by id" to separate create and update calls. -- Ids remain opaque and safe to expose in URLs. diff --git a/docs/adr/0013-use-ef-core-for-writes-and-dapper-for-reports.md b/docs/adr/0013-use-ef-core-for-writes-and-dapper-for-reports.md deleted file mode 100644 index c16ef33..0000000 --- a/docs/adr/0013-use-ef-core-for-writes-and-dapper-for-reports.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [data] ---- - -# ADR-0013: Use EF Core 10 for writes and Dapper for reporting queries - -## Context - -Most screens are forms and lists that fit an ORM well. The Relatórios area (for example Custo x Faturamento) will need aggregated queries across many tables, where hand-written SQL is clearer and faster than LINQ. - -## Decision - -- **EF Core 10** is the default for all commands and for simple queries. Migrations are created with the EF Core CLI and committed to the repository. -- Read-only queries use `AsNoTracking` and project directly to DTOs with `Select`. -- **Dapper** is used for reporting queries, with SQL kept next to the query handler and sharing the same Npgsql data source. It is added together with the first report; until then, EF Core is the only data access library. -- Migrations are applied by a dedicated step in deployment, not automatically at application startup in production. - -## Alternatives considered - -- **EF Core only.** Possible, but complex reports become hard-to-read LINQ. -- **Dapper only.** Full control, but a lot of repetitive code for simple forms. - -## Consequences - -- The portfolio shows when to use an ORM and when to leave it. -- Two data access styles must be tested; integration tests run both against a real PostgreSQL (ADR-0024). diff --git a/docs/adr/0014-use-optimistic-concurrency.md b/docs/adr/0014-use-optimistic-concurrency.md deleted file mode 100644 index 2466f0f..0000000 --- a/docs/adr/0014-use-optimistic-concurrency.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [data, api] ---- - -# ADR-0014: Use optimistic concurrency control - -## Context - -Today, if two people edit the same record and both save, the last save silently overwrites the first (`docs/product/open-questions.md`). The ERP will be used by several people at once, so lost updates are a real risk. - -## Decision - -- Every aggregate has a concurrency token. In PostgreSQL we use the `xmin` system column, mapped by Npgsql as a row version, so no extra column is needed. -- Read responses include the current version. Update and delete requests must send it back, either in an `If-Match` header or in the request body. -- If the record changed in the meantime, EF Core raises a concurrency exception, which the API returns as **409 Conflict** with the code `concurrency_conflict`. The response includes the display name of the person who made the latest change, taken from the record's `UpdatedBy` (ADR-0015), so the front-end can show: *"Este cadastro foi alterado por {nome} enquanto você editava. Recarregue para ver a versão atual."* -- Nothing is written in that case. The front-end keeps the user's input on screen and offers "Recarregar" or "Continuar aqui" (`docs/product/conventions.md`). - -## Alternatives considered - -- **Last write wins (current behavior).** Simplest, but loses data without warning. -- **Pessimistic locking.** Prevents conflicts, but locks rows while someone has a form open, which does not suit a web application. - -## Consequences - -- The front-end must keep the version of each open record and handle the 409 response with a clear dialog. -- Integration tests must cover the conflict path. diff --git a/docs/adr/0015-record-audit-fields-automatically.md b/docs/adr/0015-record-audit-fields-automatically.md deleted file mode 100644 index 533f47b..0000000 --- a/docs/adr/0015-record-audit-fields-automatically.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [data] ---- - -# ADR-0015: Record audit fields automatically - -## Context - -Records currently only have `updatedAt`, set by the browser. There is no record of when something was created or who changed it (`docs/product/open-questions.md`). An ERP needs at least basic traceability. - -## Decision - -- Every aggregate has `CreatedAt`, `CreatedBy`, `UpdatedAt` and `UpdatedBy`. -- The values are filled by an EF Core **`SaveChangesInterceptor`**, never by the client and never by handlers. -- The current user comes from an `ICurrentUser` abstraction backed by the authenticated principal (ADR-0019). -- Timestamps come from the injected **`TimeProvider`**, stored in UTC, so tests can control time. -- Read responses include the display names of `CreatedBy` and `UpdatedBy`, so the front-end can show a footer such as *"Criado por Admin em 12/09/2026 às 09:58 · Última alteração por Bruno Lima em 20/09/2026 às 14:41"*, in Brasília time. - -## Alternatives considered - -- **Set the fields in each handler.** Easy to forget. -- **A full audit log table or event sourcing.** Records every change, field by field, but is more than the project needs now. It is listed as future work in `docs/product/open-questions.md` and can be added later without changing this decision. - -## Consequences - -- Audit data is consistent across all features with no per-feature code. -- The front-end stops sending `updatedAt`. diff --git a/docs/adr/0016-deactivate-users-instead-of-deleting.md b/docs/adr/0016-deactivate-users-instead-of-deleting.md deleted file mode 100644 index d6ea4ae..0000000 --- a/docs/adr/0016-deactivate-users-instead-of-deleting.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [data, domain] ---- - -# ADR-0016: Deactivate users instead of deleting them - -## Context - -Today, deleting a user removes the record permanently. In an ERP, users will be referenced by other records (created by, responsible for a route, signed a document). Removing them breaks that history. - -## Decision - -- **Users** are deactivated, not deleted. The `User` status becomes `inactive` (ADR-0019), with `DeactivatedAt` and `DeactivatedBy` recorded. -- Deactivated users cannot sign in. Their refresh tokens are revoked, and any pending activation or password-reset link stops working. -- An EF Core **global query filter** hides deactivated users from default queries. Screens that need them, such as history or the status filter of the Users list, disable the filter explicitly. -- A deactivated user can be reactivated. If they had already activated their account, they return to `active` with the same password. If they were still `pending`, they return to `pending` and a new activation link is sent. -- Nobody can deactivate their own account, to avoid locking themselves out by mistake. -- The login and the display name of a deactivated user remain reserved, so old log entries and audit fields never point to two different people with the same name. -- The Admin user cannot be deactivated (ADR-0022). -- **Permission profiles** keep the current rule: a profile can be permanently deleted only when no user (active or inactive) has it. - -## Alternatives considered - -- **Hard delete everywhere (current behavior).** Simple, but loses history. -- **Soft delete for every entity.** Consistent, but adds filtering complexity where it brings no value. - -## Consequences - -- The front-end shows "Desativar usuário" and "Reativar usuário" instead of "Excluir usuário", and the Users list has a status filter ("Ativos e pendentes", "Só pendentes", "Só desativados", "Todos"). -- Every query must be written with the global filter in mind. diff --git a/docs/adr/0017-paginate-and-search-on-the-server.md b/docs/adr/0017-paginate-and-search-on-the-server.md deleted file mode 100644 index 6df6d4d..0000000 --- a/docs/adr/0017-paginate-and-search-on-the-server.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [data, api] ---- - -# ADR-0017: Paginate, sort and search on the server - -## Context - -The front-end loads whole collections into memory and filters them in the browser (`docs/frontend/simulated-server.md`). This works for a few dozen users, but not for clients, accounts or reports, which can reach thousands of rows. It also sends the browser data that the user may not be allowed to see, which conflicts with per-screen permissions (ADR-0020). - -## Decision - -- List endpoints accept `page`, `pageSize` (default 10, maximum 100), `sort` and `search` query parameters. The front-end uses 10 rows per page. -- Responses have a standard envelope: `items`, `page`, `pageSize`, `totalCount`. -- Search ignores accents and letter case, matching the current front-end behavior. User search covers display name, full name and CPF digits. -- Offset pagination is the default. Keyset pagination can be adopted for specific large tables later. -- **Small reference lists** that change rarely and fit on one screen, such as permission profiles, the screen catalog and, later, payment methods, are returned whole, without pagination. -- Users are filtered with a `status` parameter: `current` (active and pending, the default), `pending`, `inactive` or `all`. Inactive users are therefore excluded unless requested. - -## Alternatives considered - -- **Return everything (current behavior).** Simple, but does not scale. -- **Keyset pagination everywhere.** Faster on large tables, but makes "go to page N" impossible and adds complexity where it is not needed. - -## Consequences - -- The front-end lists must request pages and show pagination controls. -- Uniqueness checks move entirely to the server, since the client no longer has all records. diff --git a/docs/adr/0018-store-signatures-in-object-storage.md b/docs/adr/0018-store-signatures-in-object-storage.md deleted file mode 100644 index 7ddcf4e..0000000 --- a/docs/adr/0018-store-signatures-in-object-storage.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -status: proposed -date: 2026-09-23 -scope: back-end -tags: [data] ---- - -# ADR-0018: Store signatures in object storage - -## Context - -The user's signature is currently stored inside the user record as a data URL of up to about 180,000 characters (`docs/product/features/users.md`). Large binary content inside rows makes every user query heavier and the database larger. - -## Decision - -- Signature images are stored in an **S3-compatible object storage**: **MinIO** in local development and CI, and an S3-compatible service in the demo environment. -- The `User` aggregate stores only the object key. -- Uploads go through a dedicated endpoint that accepts PNG, JPEG or WebP up to 5 MB and checks the actual content type. -- The front-end displays signatures through short-lived pre-signed URLs returned by the API. The storage is never public. -- Access to the storage is behind an `IFileStorage` interface in Application, implemented in Infrastructure. - -## Alternatives considered - -- **Keep the data URL in the row (current behavior).** No extra infrastructure, but bloats queries. -- **A binary column in PostgreSQL.** Keeps everything in one place, but still inflates backups and rows. -- **Azure Blob Storage with the Azurite emulator.** Equally valid; MinIO was chosen because the S3 API is the most portable. - -## Consequences - -- One more service to run locally, handled by Aspire (ADR-0027). -- The front-end signature component must upload the image separately from the user form. diff --git a/docs/adr/0019-authenticate-with-identity-and-jwt.md b/docs/adr/0019-authenticate-with-identity-and-jwt.md deleted file mode 100644 index adb58bd..0000000 --- a/docs/adr/0019-authenticate-with-identity-and-jwt.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [security, authentication] -amended-by: ADR-0032 (session lifetime and revocation) ---- - -# ADR-0019: Authenticate with ASP.NET Core Identity, JWT and activation links - -## Context - -There is no sign-in yet. The top bar of the front-end shows a fixed name, and permissions cannot be enforced without knowing who is signed in. - -The project owner defined how accounts work (see `docs/product/features/authentication.md`): - -- Nobody signs up on their own. A new user is always created by someone with access to the Users screen. -- Users sign in with a new **`login`** field, which is unique. -- The **e-mail** is required but **not unique**: several people may share a department mailbox. -- The first access happens through an **activation link** sent by e-mail, where the person creates their own password. A temporary password sent by e-mail was considered and rejected. - -## Decision - -**Credentials.** ASP.NET Core Identity manages the password hash, lockout and security stamps. The domain `User` aggregate references the Identity user by id and holds business data, including `login`, `email` and `status`. - -**Login.** 3 to 30 characters, limited to unaccented letters, digits, `.`, `-` and `_`. Stored in lowercase and unique among all users, including inactive ones. The server can suggest a free login from the full name (`ana.souza`, then `ana.souza2`). - -**Account status.** Accounts move through three states: `pending` → `active` ↔ `inactive`. Only `active` accounts can sign in. - -**Activation.** -1. Creating a user sets the status to `pending` and sends an activation link to the registered e-mail (ADR-0030). -2. The link carries a random, single-use token, valid for 72 hours. Only its hash is stored. -3. Opening the link lets the person set a password. The account then becomes `active`. -4. "Resend access" issues a new token to the current e-mail and invalidates the previous one. - -**Password reset.** The person provides their **login**, not their e-mail, because e-mails are not unique. If the login exists and is active, a single-use reset link valid for 2 hours is sent to the registered e-mail. The response is identical whether or not the login exists, to avoid account enumeration. - -**Password rules.** Minimum of 8 characters, with no composition rules, following current NIST guidance that favors length over complexity. - -**Lockout.** 5 consecutive failures lock the login for 15 minutes. Setting a new password through a reset link also clears an active lockout. Error messages never reveal whether the login or the password was wrong. - -**Tokens.** -- A successful sign-in returns a short-lived **JWT access token** (15 minutes) and a **refresh token**. -- Refresh tokens are random, stored hashed, rotated on every use and revoked on sign-out, on password change and when the user is deactivated. -- In the browser, the access token lives in memory only. -- The refresh token travels in an `HttpOnly`, `Secure`, `SameSite=Strict` cookie scoped to the refresh endpoint. - -**Current user.** `GET /api/v1/me` returns the signed-in user's display name and effective access levels (ADR-0020). - -All durations and limits above are configuration values, not constants. - -## Alternatives considered - -- **Temporary password sent by e-mail.** Rejected by the project owner in favor of activation links. With a link, users never have to copy a random password, which suits the audience, and the mailbox never keeps a credential. This matters more because mailboxes may be shared. -- **Sign in with e-mail.** Not possible, because e-mails are not unique. -- **Cookie-only authentication.** Simple for a same-site front-end, but JWT is more common in job requirements and works for future mobile clients. -- **External identity provider (Keycloak, Auth0, Entra ID).** Production-grade, but it moves the most interesting part of the project out of the codebase. -- **`MapIdentityApi` endpoints.** Quick to set up, but they do not support the activation flow and do not issue standard JWTs. - -## Consequences - -- The user form gains **Login** and **E-mail** fields, and the Users screen gains **Resend access**. -- The front-end needs new screens: sign in, activate account, forgot password and set a new password. -- The system depends on e-mail delivery. A failed delivery must not undo the user creation (ADR-0030). -- Secrets such as the JWT signing key come from configuration and user secrets, never from the repository. diff --git a/docs/adr/0020-authorize-per-screen-with-custom-policies.md b/docs/adr/0020-authorize-per-screen-with-custom-policies.md deleted file mode 100644 index 2a7bbba..0000000 --- a/docs/adr/0020-authorize-per-screen-with-custom-policies.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [security, permissions] ---- - -# ADR-0020: Authorize per screen with custom policies - -## Context - -Permissions are defined per screen and per level (`docs/product/features/permission-profiles.md`), but they are not enforced anywhere yet. The effective level of a user is the highest level among their profiles. What each level allows on each screen was also still undefined. This is the most distinctive business rule of the project and the main showcase of the portfolio. - -## Decision - -**Requirement and handler.** A custom authorization requirement, `ScreenAccessRequirement(ScreenKey, AccessLevel minimum)`, is evaluated by a handler that: - -1. reads the user id from the token; -2. obtains the user's effective level for the screen, which is the highest level among all of the user's profiles (ADR-0006); -3. succeeds if the effective level is at least the minimum. - -**Endpoint usage.** Endpoints declare it through an extension method, for example `.RequireScreenAccess(ScreenKeys.Users, AccessLevel.Editor)`. - -**Default mapping of levels to operations:** - -| Operation | Minimum level | -|---|---| -| List and view | Reader (`leitor`) | -| Create, edit, duplicate a profile, resend access | Editor (`editor`) | -| Delete a profile, deactivate or reactivate a user | Manager (`gerenciador`) | - -Exceptions are documented on the endpoint and in the OpenAPI description. The first one already exists: listing permission profiles is allowed with Reader on **either** the Permissions screen or the Users screen, because the user form needs the list of profiles to choose from. - -This mapping is already applied by the front-end's simulated server and user interface (`docs/product/features/permission-profiles.md`). The project owner confirmed it on 2026-09-24, including that a Reader on the Users screen can see people's signatures. - -**Caching.** Effective permissions, together with the account status (ADR-0032), are cached per user with **HybridCache**, in memory only while the API runs as a single instance. Redis is added as the distributed layer when a second instance exists. Entries are invalidated when a user's profiles or status change or a profile's levels change. - -**Front-end support.** `GET /api/v1/me` returns the effective level for every screen. The front-end uses it to apply the visibility rules confirmed by the project owner (`docs/product/features/permission-profiles.md`): - -- a screen whose effective level is `negado` is not shown in the menu or in the screen search; -- a menu area whose screens are all `negado` is not shown at all; -- opening a denied screen through a saved link or a restored tab shows a "no access" message, and the API returns 403 for its data; -- a user with no accessible screen sees an empty menu and a message on the home screen. - -The front-end check is only for user experience; the API is the authority. - -## Alternatives considered - -- **Role-based authorization (`[Authorize(Roles = ...)]`).** Cannot express "a level per screen" without an explosion of roles. -- **Checks inside each handler.** Works, but spreads authorization across the codebase and is easy to forget. -- **Putting all permissions in the JWT.** Avoids lookups, but changes would only apply after the token expires, and the token would grow with every new screen. - -## Consequences - -- Permission changes take effect on the next request after cache invalidation, without signing out. -- Integration tests must cover allowed and denied paths for each level. -- The level-to-operation table becomes the reference for all future screens. diff --git a/docs/adr/0021-define-screen-keys-as-stable-constants.md b/docs/adr/0021-define-screen-keys-as-stable-constants.md deleted file mode 100644 index 485a24c..0000000 --- a/docs/adr/0021-define-screen-keys-as-stable-constants.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [permissions, domain] ---- - -# ADR-0021: Define screen keys as stable constants owned by the back-end - -## Context - -Screen keys such as `gerenciamento/usuarios` are currently derived from the screen names in the front-end menu. Renaming a screen changes its key and silently invalidates every permission stored for it (`docs/product/open-questions.md`). - -### Example - -Suppose "Relatório de Vendas" is renamed to "Vendas por Período". - -- **Key derived from the name:** the key silently changes from `relatorios/relatorio-de-vendas` to `relatorios/vendas-por-periodo`. No profile has a level for the new key, and a missing level means `negado`. The screen therefore **disappears for everyone** except holders of the Gerenciador profile, with no warning, and every profile must be reconfigured by hand. -- **Stable key:** the key stays `relatorios/relatorio-de-vendas` and only the display name changes. All permissions keep working. - -Fixing an accent ("Relatorio" → "Relatório") or changing "x" to "vs." in "Custo x Faturamento" would cause the same problem. - -Separating an identifier from its presentation is standard practice: products keep an internal code when their description changes, and translated applications use keys such as `menu.reports.sales` instead of the displayed text. - -## Decision - -- The back-end defines every screen key as a constant in a `ScreenKeys` class in the Domain project. This class is the single source of truth. -- The existing keys are kept exactly as they are (see `docs/product/screen-catalog.json`), so stored permissions remain valid. The `area/screen` format is only a readability convention; once created, a key never follows changes to the display name. -- Keys are never reused. A retired key is not assigned to a different screen. -- The API exposes the catalog (keys, display names, areas) at `GET /api/v1/screens`, used by the permission screen of the front-end. -- The front-end menu stores each screen's key explicitly instead of deriving it from the name. -- Removing a screen is a deliberate change: its key is marked as retired, and stored levels for it are cleaned up by a migration. - -## Alternatives considered - -- **Keep deriving keys from names.** No change needed, but renaming a screen breaks permissions. -- **Numeric screen ids.** Stable, but unreadable in data and logs. - -## Consequences - -- Display names can change freely without touching permissions. -- Adding a screen requires changes in both the back-end constants and the front-end menu. diff --git a/docs/adr/0022-seed-and-protect-system-records.md b/docs/adr/0022-seed-and-protect-system-records.md deleted file mode 100644 index 724e03a..0000000 --- a/docs/adr/0022-seed-and-protect-system-records.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [security, data] ---- - -# ADR-0022: Seed and protect system records - -## Context - -Two records must always exist and can never be changed: the **Gerenciador** profile, with the highest level on every screen, and the **Admin** user, who has that profile. Today they are hard-coded in the front-end and never stored (`docs/product/features/users.md`). - -## Decision - -- Both records are stored in the database and created by **EF Core seeding** with fixed, well-known GUIDs (ADR-0012), exposed as constants. -- Both carry an `IsSystem` flag. The aggregates reject any change or deletion of a system record with a domain error, returned as 409 Conflict. -- The Gerenciador profile has no stored levels. The effective-access service treats it as Manager on every screen, including screens added in the future. -- The Admin signs in with the fixed login `admin`. Its e-mail and initial password come from configuration (user secrets locally, environment variables in deployment) and are never committed. This bootstrap password is the only exception to the activation-link flow (ADR-0019), because the first administrator has nobody to invite them. The Admin must change it on first sign-in. -- System records cannot be deactivated, and the Admin cannot lose the Gerenciador profile. - -## Alternatives considered - -- **Keep system records only in code.** Avoids seeding, but joins and foreign keys to them become special cases. -- **Store the Gerenciador levels explicitly.** Uniform with other profiles, but new screens would need a data migration to stay fully accessible. - -## Consequences - -- Queries and foreign keys treat system records like any other record. -- The front-end no longer adds these records to lists itself; it receives them from the API with an `isSystem` flag. diff --git a/docs/adr/0023-rate-limit-authentication-endpoints.md b/docs/adr/0023-rate-limit-authentication-endpoints.md deleted file mode 100644 index 0799266..0000000 --- a/docs/adr/0023-rate-limit-authentication-endpoints.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [security, authentication] ---- - -# ADR-0023: Rate limit authentication endpoints - -## Context - -Sign-in and token refresh endpoints are the main target of brute-force and credential-stuffing attempts. A public portfolio demo will be exposed to the internet. - -## Decision - -- Use the built-in ASP.NET Core **rate limiting middleware** on authentication endpoints, with a sliding window per client IP. -- Endpoints that send e-mails ("forgot password" and "resend access") get stricter limits, both per IP and per target login, so they cannot be used to flood a mailbox (ADR-0030). -- Combine it with **Identity lockout**, which blocks an account after repeated failed attempts, regardless of IP. -- Rejected requests return **429 Too Many Requests** with a `Retry-After` header and a Portuguese message. -- A looser global limit protects the rest of the API. - -## Alternatives considered - -- **Rely on lockout only.** Protects accounts, but not the service itself. -- **Rate limiting only at a reverse proxy or gateway.** Valid in production, but keeps the behavior out of the codebase and out of tests. - -## Consequences - -- Limits must be tuned so that normal use, such as several people behind the same office IP, is not blocked. -- Integration tests should confirm the 429 behavior. diff --git a/docs/adr/0024-testing-strategy.md b/docs/adr/0024-testing-strategy.md deleted file mode 100644 index 0c7a37e..0000000 --- a/docs/adr/0024-testing-strategy.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [quality, testing] -amended-by: ADR-0033 (test doubles and when tests are written) ---- - -# ADR-0024: Testing strategy - -## Context - -The business rules (CPF validation, uniqueness, effective permissions, protection of system records) are the core of the project. Tests are also one of the first things reviewers look at in a portfolio. Some popular test libraries changed licenses recently: FluentAssertions was licensed under Apache 2.0 up to version 7, and later versions require a paid license for commercial use. - -## Decision - -| Layer | What is tested | Tools | -|---|---|---| -| Domain | Value objects, aggregates, effective permission | xUnit, Shouldly | -| Application | Handlers and validators with fake dependencies | xUnit, Shouldly, NSubstitute | -| API integration | Real HTTP calls, authentication, authorization, persistence | `WebApplicationFactory`, Testcontainers (PostgreSQL, Mailpit; MinIO when ADR-0018 is implemented) | -| Architecture | Dependency rules between projects | NetArchTest (ADR-0025) | - -- **xUnit v3** is the test framework, running on **Microsoft.Testing.Platform** (enabled in `global.json`). Coverage uses `Microsoft.Testing.Extensions.CodeCoverage`. -- **Shouldly** is used for assertions. AwesomeAssertions, the community fork of FluentAssertions 7, is an acceptable alternative. -- Integration tests run against **real containers**, not the EF Core in-memory provider, so SQL behavior, indexes and concurrency tokens are exercised. -- Test names describe behavior (`Cpf_with_all_equal_digits_is_rejected`). -- The account flows have integration tests that read the Mailpit inbox (ADR-0030): activation link sent on creation, link expired after 72 hours (using a fake `TimeProvider`), link invalid after use or after "resend access", identical response to password-reset requests for existing and unknown logins. -- The effective-permission rule has dedicated domain tests covering at least: `Denied` in one profile and `Editor` in another results in `Editor`; a screen missing from a profile counts as `Denied`; the Gerenciador profile results in `Manager` on every screen, including screens added later; an unknown profile id is ignored; a user with no profiles results in `Denied` on every screen. -- Code coverage is collected in CI and reported, without a hard threshold at first. - -## Alternatives considered - -- **FluentAssertions 8.** Rich API, but no longer free for commercial use. -- **EF Core in-memory provider or SQLite for integration tests.** Faster, but behaves differently from PostgreSQL. -- **Moq for mocks.** Popular; NSubstitute was chosen for its simpler syntax. - -## Consequences - -- Integration tests need Docker locally and in CI; GitHub-hosted Linux runners provide it. -- The test suite documents the business rules as executable examples. - -## References - -- [AwesomeAssertions](https://github.com/AwesomeAssertions/AwesomeAssertions) diff --git a/docs/adr/0025-enforce-architecture-rules-with-tests.md b/docs/adr/0025-enforce-architecture-rules-with-tests.md deleted file mode 100644 index c6275d1..0000000 --- a/docs/adr/0025-enforce-architecture-rules-with-tests.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [quality, testing] ---- - -# ADR-0025: Enforce architecture rules with tests - -## Context - -Clean Architecture (ADR-0005) only works if the dependency rules are respected. Over time it is easy to reference EF Core from the Domain, or an Infrastructure class from an endpoint, by accident. - -## Decision - -A `ControlService.ArchitectureTests` project uses **NetArchTest** to verify, on every build: - -- Domain does not depend on Application, Infrastructure, Api, EF Core or ASP.NET Core. -- Application does not depend on Infrastructure, Api or EF Core. -- Api does not use Infrastructure types outside the composition root. -- Handlers, validators and endpoints follow the naming conventions (`...Handler`, `...Validator`, `...Endpoints`). - -## Alternatives considered - -- **Code review only.** Relies on attention; violations slip through. -- **ArchUnitNET.** More expressive, with a steeper learning curve; either would work. - -## Consequences - -- Architectural violations fail the build instead of being discovered months later. -- Legitimate exceptions must be made explicit in the tests. diff --git a/docs/adr/0026-enforce-build-quality-settings.md b/docs/adr/0026-enforce-build-quality-settings.md deleted file mode 100644 index e183b01..0000000 --- a/docs/adr/0026-enforce-build-quality-settings.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [quality] ---- - -# ADR-0026: Enforce build quality settings across the solution - -## Context - -With several projects, package versions and compiler settings tend to drift apart. Nullable reference types and analyzers catch whole classes of bugs, but only if they are on everywhere. - -## Decision - -- **Central Package Management**: every package version is declared once in `Directory.Packages.props`. -- **`Directory.Build.props`** applies to every project: - - `Nullable` enabled - - `ImplicitUsings` enabled - - `TreatWarningsAsErrors` enabled - - `AnalysisLevel` set to `latest-recommended` - - `EnforceCodeStyleInBuild` enabled -- An **`.editorconfig`** defines code style and analyzer severities. -- **`global.json`** pins the .NET 10 SDK (ADR-0002). -- NuGet **package auditing** is enabled, so known vulnerable dependencies fail the build. - -## Alternatives considered - -- **Per-project settings.** Flexible, but inconsistent. -- **Warnings without failing the build.** Less friction, but warnings accumulate and get ignored. - -## Consequences - -- The codebase stays consistent, and new projects inherit the rules automatically. -- Treating warnings as errors can slow down experiments; suppressions must be local and justified. diff --git a/docs/adr/0027-use-dotnet-aspire-for-local-development.md b/docs/adr/0027-use-dotnet-aspire-for-local-development.md deleted file mode 100644 index 0d18f1d..0000000 --- a/docs/adr/0027-use-dotnet-aspire-for-local-development.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -accepted: 2026-09-24 -scope: back-end -tags: [operations] ---- - -# ADR-0027: Use .NET Aspire for local development - -## Context - -Running the API locally requires PostgreSQL, Redis (for HybridCache, ADR-0020), MinIO (ADR-0018) and Mailpit (ADR-0030). Setting these up by hand, with matching connection strings, is a common source of friction. Aspire 13 was released together with .NET 10. - -## Decision - -- The solution includes an **Aspire AppHost** project that starts PostgreSQL, Mailpit and the API with one command. Connection strings are injected automatically. Redis (ADR-0020) and MinIO (ADR-0018) are added to the AppHost when those decisions are implemented. -- A **ServiceDefaults** project configures OpenTelemetry, health checks, service discovery and HTTP resilience for the API (ADR-0028). -- The **Aspire dashboard** is used locally to inspect logs, traces and metrics. -- Aspire is used for development only. Deployment uses container images (ADR-0029). - -## Alternatives considered - -- **Docker Compose.** Universal and well known, but connection strings and telemetry must be wired by hand. A `compose.yaml` can still be generated for people who prefer it. -- **Manual installation of each service.** Maximum friction for anyone cloning the repository. - -## Consequences - -- Anyone cloning the repository can run the whole system with Docker and the .NET SDK installed. -- The solution gains two infrastructure projects (AppHost and ServiceDefaults) in addition to the four Clean Architecture projects. diff --git a/docs/adr/0028-observability-with-serilog-and-opentelemetry.md b/docs/adr/0028-observability-with-serilog-and-opentelemetry.md deleted file mode 100644 index da5b325..0000000 --- a/docs/adr/0028-observability-with-serilog-and-opentelemetry.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -status: proposed -date: 2026-09-23 -scope: back-end -tags: [operations] ---- - -# ADR-0028: Observability with Serilog and OpenTelemetry - -## Context - -When something fails in the demo or in a reviewer's hands, the cause must be found quickly. Error responses already carry a `traceId` (ADR-0009), which is only useful if logs and traces can be searched by it. - -## Decision - -- **Serilog** for structured logging, with request logging that records method, path, status, duration, user id and **display name**. The id is the stable reference; the display name is unique (see `docs/product/features/users.md`), so people reading the logs can recognize who acted without looking the id up. Personal data such as CPF, phone and signatures is never logged, and neither are passwords, tokens or activation and reset links. -- **OpenTelemetry** for traces and metrics (ASP.NET Core, HttpClient, EF Core, Npgsql), configured in ServiceDefaults (ADR-0027) and exported over OTLP. - - Locally, the destination is the Aspire dashboard. - - In the demo environment, it is the hosting platform's monitoring or any OTLP-compatible backend. -- **Health checks** at `/health/live` (process is up) and `/health/ready` (database and storage reachable), used by the container platform. - -## Alternatives considered - -- **Built-in `ILogger` only.** Sufficient for development, but less convenient for structured sinks and enrichment. -- **A vendor-specific agent.** Easy to set up, but ties the code to one vendor; OpenTelemetry keeps it portable. - -## Consequences - -- Any error reported by a user can be traced end to end using the `traceId` shown in the error response. -- Logging rules must be reviewed whenever new personal data is added. diff --git a/docs/adr/0029-containerize-and-automate-ci-cd.md b/docs/adr/0029-containerize-and-automate-ci-cd.md deleted file mode 100644 index 836f6ba..0000000 --- a/docs/adr/0029-containerize-and-automate-ci-cd.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -status: proposed -date: 2026-09-23 -scope: back-end -tags: [operations] ---- - -# ADR-0029: Containerize the API and automate CI/CD - -## Context - -A portfolio project is much stronger when reviewers can see a green pipeline and try a live demo. Builds must also be reproducible on any machine. - -## Decision - -**Containers.** The API is published as an OCI image with the .NET SDK container support (`dotnet publish` with the `PublishContainer` target), without a hand-written Dockerfile. The image runs as a non-root user. - -**Continuous integration.** GitHub Actions runs on every pull request and on every push to `main`: - -1. restore and build, with warnings as errors (ADR-0026); -2. run unit, architecture and integration tests (Testcontainers, ADR-0024); -3. collect and publish code coverage; -4. check that the committed `openapi.json` matches the generated one (ADR-0004). - -**Continuous delivery.** On `main`, the image is pushed to GitHub Container Registry and deployed to a container hosting platform for the public demo. **Azure Container Apps** is the preferred target because it fits the .NET ecosystem; the final provider is still open. - -**Database migrations** run as a separate pipeline step before the new version starts (ADR-0013). - -## Alternatives considered - -- **Hand-written Dockerfile.** Full control and well known; SDK container publishing needs less maintenance. A Dockerfile can be added if a custom image becomes necessary. -- **Azure DevOps Pipelines.** Common in companies, but GitHub Actions keeps everything visible in the public repository. - -## Consequences - -- The README can show build status and coverage badges and link to the live demo. -- Hosting may have a cost; the demo environment should use the smallest available tier and scale to zero when idle. diff --git a/docs/adr/0030-send-transactional-emails.md b/docs/adr/0030-send-transactional-emails.md deleted file mode 100644 index a007fa2..0000000 --- a/docs/adr/0030-send-transactional-emails.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -status: accepted -date: 2026-09-23 -scope: back-end -tags: [security, email] -superseded-by: ADR-0031 (delivery of activation e-mails only) ---- - -# ADR-0030: Send transactional e-mails through SMTP with Mailpit in development - -## Context - -Account activation and password reset depend on e-mails (ADR-0019). These messages must be reliable, easy to inspect during development and testing, and must never reach real people from a local machine or a CI run. - -## Decision - -**Abstraction.** The Application project defines an `IEmailSender` interface, implemented in Infrastructure. - -**Transport.** Messages are sent over **SMTP** using **MailKit**. Microsoft recommends MailKit over the older `System.Net.Mail.SmtpClient` for new development. - -**Development and CI.** **Mailpit** runs as a container, started by the Aspire AppHost (ADR-0027) and by Testcontainers in integration tests. It accepts every message and shows it in a local web inbox, so activation links can be opened during development. Nothing is actually delivered. - -**Production.** Any SMTP-capable transactional e-mail provider, configured by environment variables. The provider for the public demo is still open (`docs/product/open-questions.md`). - -**Delivery.** -- E-mails are sent **after** the database transaction commits, through an in-process background queue (`Channel` consumed by a `BackgroundService`), with a few retries. -- A failed delivery never rolls back the user creation. The failure is recorded on the pending account and shown on the Users screen, so someone can use **Resend access**. - -**Content.** -- Messages are in Portuguese, written in the same plain tone as the rest of the system. -- They are rendered from templates, in both HTML and plain text. -- They state who created the account and include only the link. They never contain a password. - -**Security.** -- Links point to the front-end and carry only the random token. -- Tokens and links are never written to logs (ADR-0028). - -## Alternatives considered - -- **A provider's HTTP API and SDK.** Often richer (delivery status, webhooks), but ties the code to one vendor. SMTP keeps the provider replaceable by configuration. -- **`System.Net.Mail.SmtpClient`.** Built in, but not recommended for new development. -- **Sending inside the request.** Simpler, but a slow or failing mail server would slow down or fail the user creation. -- **A transactional outbox table.** Guarantees delivery across restarts. It is a good future improvement, but more than this stage needs. - -## Consequences - -- Developers can test the complete activation flow locally without any real e-mail account. -- Messages queued in memory are lost if the process stops before sending them. "Resend access" covers that case. -- Integration tests can read the Mailpit inbox to assert that the right link was sent to the right address. diff --git a/docs/adr/0031-send-access-emails-within-the-request.md b/docs/adr/0031-send-access-emails-within-the-request.md deleted file mode 100644 index 1b92358..0000000 --- a/docs/adr/0031-send-access-emails-within-the-request.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -status: accepted -date: 2026-09-24 -accepted: 2026-09-24 -scope: back-end -tags: [authentication, email] -supersedes: ADR-0030 (delivery of activation e-mails only) ---- - -# ADR-0031: Send activation e-mails within the request - -## Context - -ADR-0030 sends every e-mail through an in-process background queue after the database transaction commits. But the front-end contract needs to know the outcome **in the response**: - -- `createUser` returns `{ user, emailSent }`, `resendAccess` returns `{ emailSent, email }` and `reactivateUser` returns `{ user, emailSent }` (`docs/product/features/users.md`); -- the screens show different messages depending on it: *"Enviamos o link de ativação para {e-mail}"* or *"…mas não conseguimos enviar o e-mail de ativação. Use 'Reenviar acesso' para tentar de novo."* (`docs/product/features/authentication.md`). - -With a background queue, the server does not know yet whether the e-mail left when it answers. - -The project owner also asked whether the user should only be saved after the link is confirmed. It should not: the user must exist first, as `pending`, because the link must point to a stored account, the login and display name must be reserved immediately, and the Users screen must list pending accounts so someone can resend access. A lost e-mail never requires deleting the user: correcting the address and using "Reenviar acesso" solves it. - -## Decision - -- **Activation e-mails** (create user, resend access, reactivate a user who never activated) are sent **within the request**, right **after** the database transaction commits, through `IEmailSender`. -- The send has a short, configurable timeout (`Email:SendTimeoutSeconds`, 10 seconds by default). -- The outcome is returned as `emailSent`. A failure or timeout never rolls back the saved user: the account stays `pending`, the new token stays valid, and "Reenviar acesso" issues another one. -- Because the response already tells the outcome, the account does **not** store a delivery-failure field. -- **Password-reset e-mails keep the background delivery** described in ADR-0030. Their response must be identical, in content **and in duration**, whether or not the login exists. Sending in the request would make responses for existing logins measurably slower and reveal which logins exist. -- The security rules of ADR-0030 still apply: links carry only the random token, and tokens are never logged. - -## Alternatives considered - -- **Background queue for every e-mail (ADR-0030).** Faster responses, but the front-end would have to change its messages to "estamos enviando" and show failures later on the user's record. -- **Save the user only after the link is confirmed.** Rejected by the reasons in the context: nothing to attach the token to, and logins could be taken twice while both links are pending. -- **Transactional outbox.** Guarantees delivery across restarts; still a possible future improvement. - -## Consequences - -- Creating a user takes as long as the SMTP handshake; with Mailpit locally this is milliseconds, and the timeout bounds the worst case. -- The messages in `docs/product/features/authentication.md` stay exactly as they are. -- Integration tests cover `emailSent = false` by making the SMTP server unreachable, and check that the user was still saved as `pending`. -- The application keeps two delivery paths: direct for activation, queued for password reset. diff --git a/docs/adr/0032-define-session-lifetime-and-per-request-account-checks.md b/docs/adr/0032-define-session-lifetime-and-per-request-account-checks.md deleted file mode 100644 index 0e27a4e..0000000 --- a/docs/adr/0032-define-session-lifetime-and-per-request-account-checks.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -status: accepted -date: 2026-09-24 -accepted: 2026-09-24 -scope: back-end -tags: [security, authentication] -amends: ADR-0019 (session lifetime and revocation) ---- - -# ADR-0032: Define the session lifetime and check the account on every request - -## Context - -ADR-0019 issues a 15-minute JWT access token and a rotating refresh token, but it does not say **how long the refresh token lives**, which is what decides when the person sees *"Sua sessão terminou. Entre de novo para continuar."* The simulated server ends a session after **8 hours without any action** (`docs/frontend/simulated-server.md`). - -Two business rules also do not fit a self-contained token that stays valid for 15 minutes: - -- a deactivated person *"sai do sistema na próxima ação"* (`docs/product/features/users.md`); -- until the Admin changes the initial password, *"nenhuma outra tela fica disponível"*. - -## Decision - -**Session lifetime.** -- Access token: 15 minutes (unchanged). -- Refresh token: **8 hours, sliding**. Every successful refresh rotates the token and the new one is valid for 8 hours from that moment. After 8 hours without a refresh, the session ends. -- The front-end refreshes **only when it needs to make a request** and the access token has expired, never on a timer. This way "8 hours without any action" really means without any action by the person. -- On page load, the front-end calls the refresh endpoint to find out whether a session exists (the `hasSession` operation). -- Configuration: `Auth:AccessTokenMinutes` (15) and `Auth:RefreshTokenIdleHours` (8). - -**Account check on every request.** -- The authorization handler of ADR-0020 loads, for the user in the token, the **account status together with the effective levels**, from the same HybridCache entry. -- If the status is not `active`, the request is rejected with **401 `account_inactive`**, whatever the endpoint. -- Deactivating a user invalidates that cache entry and revokes the user's refresh tokens (ADR-0019). The next request, even with a still-valid access token, is rejected. - -**Mandatory password change.** -- While the Admin has not replaced the initial password, the access token carries the claim `must_change_password`. -- A filter on the `/api/v1` group rejects every endpoint with **401 `password_change_required`**, except change password, `me` and sign out. -- Changing the password issues new tokens without the claim. - -## Alternatives considered - -- **Fixed 8 hours from sign-in.** Safer, but can interrupt someone in the middle of a form. -- **7 days sliding.** More comfortable, but risky on shared computers, which are common among the users. -- **Trust the token until it expires.** No lookup per request, but a deactivated person keeps access for up to 15 minutes. -- **Short-lived tokens only (for example 1 minute).** Reduces the window, but multiplies refresh traffic and still leaves a window. - -## Consequences - -- Behavior matches the simulated server, so the front-end rules do not change. -- Every authenticated request reads the cache; the database is hit only on a cache miss. -- Integration tests cover: a refresh after 8 idle hours fails (fake `TimeProvider`); a deactivated user's next request gets 401; the Admin with the initial password gets 401 `password_change_required` on any other endpoint. diff --git a/docs/adr/0033-develop-test-first-in-pair-mode.md b/docs/adr/0033-develop-test-first-in-pair-mode.md deleted file mode 100644 index 3f903d2..0000000 --- a/docs/adr/0033-develop-test-first-in-pair-mode.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -status: accepted -date: 2026-09-25 -accepted: 2026-09-25 -scope: back-end -tags: [process, testing, quality] -amends: ADR-0024 (test doubles and when tests are written) -superseded-by: AGENTS.md (pair mode and the pauses) ---- - -# ADR-0033: Develop test-first, in pair mode, with in-memory fakes - -## Context - -ADR-0024 defines what is tested and with which tools, but not **when** tests are written. The agent guidelines only required domain rules to be test-first, so handlers, persistence and endpoints could be written before their tests. - -The project owner wants all development to follow test-driven development (TDD). Code is written by AI agents working as pair programmers with the owner, a junior developer who is learning the practice through this project. Two needs follow: the owner must keep control of the direction of each change, and the agents must prove each step instead of claiming it. - -ADR-0024 also chose NSubstitute for the application layer. Mocks that verify internal calls tie tests to the current implementation, which makes the Refactor step of TDD expensive. - -## Decision - -- **Every behavior change starts with a failing test**, in every layer: unit tests for the domain and the application, integration tests for persistence and the API. The cycle is Red → Green → Refactor, with the two rules "write code only to make a failing test pass" and "eliminate duplication". -- **Pair mode is the default.** The owner is the navigator and chooses the next test; the agent is the driver. The owner approves a test list before the first cycle. -- **One pause per test cycle.** The agent runs Red, Green and Refactor for one test, then pauses once. At the pause it shows each phase with its real output: - - **Red:** the test, the failure and why it is the expected one. - - **Green:** the code and the strategy. - - **Refactor:** what changed, or "nothing to refactor". - - The suite status, and the next candidate tests. -- The agent **stops in the middle of a cycle**, before going on, when: - - a test fails for an unexpected reason, or passes when it should fail; - - a business rule or a design decision belongs to the owner. -- On any task, the owner can ask for a pause after each phase ("pause a cada fase"). -- **Autonomous mode** is used only when the owner asks for it on a task. The phases are the same; each one is proven by the command output in a per-cycle log. -- **Test doubles:** hand-written in-memory fakes are preferred, and tests assert on outcomes. NSubstitute stays available for cases where a fake would be clearly heavier, but not to verify internal calls. -- Pieces with no meaningful Red (dependency injection wiring, configuration, mapping details, migrations) are covered by integration tests, and the agent says so explicitly. -- The procedure is described in the [`/executar-issue` skill](../../.claude/skills/executar-issue/SKILL.md). - -## Alternatives considered - -- **Test-after.** Faster at first, but tests shaped by the code tend to confirm the implementation instead of the rules, and the owner would not learn TDD. -- **TDD only in the domain.** Covers the rules, but lets handlers and endpoints grow untested paths; rejected by the owner. -- **Autonomous mode by default.** Much faster for issues with dozens of tests, but the owner would review results instead of steering each step; the owner chose to learn with the pauses. -- **A pause after each phase.** The first version of this record. Three pauses per test slowed the work without adding decisions: the next choice only comes after the Refactor, and the report at the end of the cycle still shows every phase. -- **Mocks first (NSubstitute everywhere).** Less test code, but brittle under refactoring. - -## Consequences - -- Each issue takes more interaction time; the owner can switch a task to autonomous mode when it is routine. -- A Red that fails for the wrong reason must be caught by the agent, which is why it stops in that case instead of going on. -- The test suite documents the rules through rule IDs and becomes the safety net for refactoring. -- The test projects gain a small set of fakes that later features reuse. -- Pull requests state that the change was developed test-first. - -## Revisions - -- **2026-09-27:** the pause moved from after each phase to once per test cycle. The owner asked for it during issue #5, where 43 tests meant about 130 pauses. As an exception decided by the owner, this record was updated in place instead of being superseded. -- **2026-10-05:** pair mode and the pause per test cycle were replaced by continuous execution with a final report, one result per numbered test ([AGENTS.md](../../AGENTS.md#tests)). The owner asked for it to finish the product sooner and review each test afterwards. The test-first rule, the fakes and the rule for pieces with no meaningful Red still apply. diff --git a/docs/adr/README.md b/docs/adr/README.md deleted file mode 100644 index 77f56e4..0000000 --- a/docs/adr/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Architecture Decision Records - -This folder records the architectural decisions for the Control Service back-end, built in C# with .NET. The process is described in [ADR-0001](0001-record-architecture-decisions.md) and step by step in the [record a decision](../agents/workflows/record-a-decision.md) workflow; new records start from [template.md](template.md). - -**29** records are **accepted**, because the project owner explicitly confirmed them; the others are still **proposed**. When a decision changes, write a new record and mark the old one as superseded instead of editing an accepted record. - -What the system does is described in [`docs/product/`](../product/); open questions are in [open-questions.md](../product/open-questions.md). - -## Organization - -- **One flat folder, ordered by number.** The number is the identity of a record: it never changes and is never reused. Records are not split into subfolders, because a decision often belongs to several categories and moving files would break links. Categories live in the tags and in the index below. -- **File name:** `NNNN-short-title-in-the-imperative.md`. - -## Metadata - -Every record starts with YAML front matter, so people and agents can filter records without reading them all. GitHub renders it as a table. - -```yaml ---- -status: accepted # proposed | accepted | superseded | deprecated -date: 2026-09-24 # when the record was written -accepted: 2026-09-24 # when the owner accepted it (accepted records only, when known) -scope: back-end -tags: [authentication, email] # one or more from the vocabulary below -supersedes: ADR-0030 (delivery of activation e-mails only) # optional relation ---- -``` - -| Field | Values | -|---|---| -| `status` | `proposed`, `accepted`, `superseded`, `deprecated` | -| Relations (optional) | `supersedes`, `superseded-by`, `amends`, `amended-by`. A partial relation names the part in parentheses; the record keeps `status: accepted` while the rest of it still applies | -| `tags` | `process`, `platform`, `api`, `architecture`, `domain`, `data`, `security`, `authentication`, `permissions`, `email`, `quality`, `testing`, `operations`. Add a new tag here before using it | - -To list the accepted security records, for example: search the folder for `status: accepted` and `tags:` containing `security`. - -## Process - -| ADR | Decision | Status | -|---|---|---| -| [0001](0001-record-architecture-decisions.md) | Record architecture decisions | Proposed | - -## Platform and API - -| ADR | Decision | Status | -|---|---|---| -| [0002](0002-use-dotnet-10-lts.md) | Use .NET 10 LTS and C# 14 | Accepted | -| [0003](0003-use-minimal-apis-grouped-by-feature.md) | Use Minimal APIs grouped by feature | Accepted, validation through endpoint filters superseded by 0008 | -| [0004](0004-use-built-in-openapi-with-scalar.md) | Use built-in OpenAPI generation with the Scalar UI | Accepted | - -## Architecture - -| ADR | Decision | Status | -|---|---|---| -| [0005](0005-adopt-clean-architecture.md) | Adopt Clean Architecture with four projects | Accepted | -| [0006](0006-apply-tactical-ddd-in-the-domain.md) | Apply tactical DDD in the domain | Accepted | -| [0007](0007-implement-cqrs-without-mediatr.md) | Implement CQRS with in-house handlers instead of MediatR | Accepted | -| [0008](0008-validate-input-with-fluentvalidation.md) | Validate input with FluentValidation | Accepted | -| [0009](0009-use-result-pattern-and-problem-details.md) | Use the Result pattern for business errors and Problem Details for HTTP errors | Accepted | -| [0010](0010-map-objects-without-automapper.md) | Map objects manually, with Mapperly as an option | Accepted | - -## Data - -| ADR | Decision | Status | -|---|---|---| -| [0011](0011-use-postgresql.md) | Use PostgreSQL as the primary database | Accepted | -| [0012](0012-use-uuid-v7-identifiers.md) | Use UUID v7 identifiers generated by the server | Accepted | -| [0013](0013-use-ef-core-for-writes-and-dapper-for-reports.md) | Use EF Core 10 for writes and Dapper for reporting queries | Accepted | -| [0014](0014-use-optimistic-concurrency.md) | Use optimistic concurrency control | Accepted | -| [0015](0015-record-audit-fields-automatically.md) | Record audit fields automatically | Accepted | -| [0016](0016-deactivate-users-instead-of-deleting.md) | Deactivate users instead of deleting them | Accepted | -| [0017](0017-paginate-and-search-on-the-server.md) | Paginate, sort and search on the server | Accepted | -| [0018](0018-store-signatures-in-object-storage.md) | Store signatures in object storage | Proposed | - -## Security and access - -| ADR | Decision | Status | -|---|---|---| -| [0019](0019-authenticate-with-identity-and-jwt.md) | Authenticate with ASP.NET Core Identity, JWT and activation links | Accepted, amended by 0032 | -| [0020](0020-authorize-per-screen-with-custom-policies.md) | Authorize per screen with custom policies | Accepted | -| [0021](0021-define-screen-keys-as-stable-constants.md) | Define screen keys as stable constants owned by the back-end | Accepted | -| [0022](0022-seed-and-protect-system-records.md) | Seed and protect system records | Accepted | -| [0023](0023-rate-limit-authentication-endpoints.md) | Rate limit authentication endpoints | Accepted | -| [0030](0030-send-transactional-emails.md) | Send transactional e-mails through SMTP with Mailpit in development | Accepted, delivery of activation e-mails superseded by 0031 | -| [0031](0031-send-access-emails-within-the-request.md) | Send activation e-mails within the request | Accepted | -| [0032](0032-define-session-lifetime-and-per-request-account-checks.md) | Define the session lifetime and check the account on every request | Accepted | - -## Quality - -| ADR | Decision | Status | -|---|---|---| -| [0024](0024-testing-strategy.md) | Testing strategy | Accepted, amended by 0033 | -| [0025](0025-enforce-architecture-rules-with-tests.md) | Enforce architecture rules with tests | Accepted | -| [0026](0026-enforce-build-quality-settings.md) | Enforce build quality settings across the solution | Accepted | -| [0033](0033-develop-test-first-in-pair-mode.md) | Develop test-first, in pair mode, with in-memory fakes | Accepted, pair mode and the pauses superseded by [AGENTS.md](../../AGENTS.md#tests) | - -## Operations - -| ADR | Decision | Status | -|---|---|---| -| [0027](0027-use-dotnet-aspire-for-local-development.md) | Use .NET Aspire for local development | Accepted | -| [0028](0028-observability-with-serilog-and-opentelemetry.md) | Observability with Serilog and OpenTelemetry | Proposed | -| [0029](0029-containerize-and-automate-ci-cd.md) | Containerize the API and automate CI/CD | Proposed | diff --git a/docs/adr/template.md b/docs/adr/template.md deleted file mode 100644 index 433f673..0000000 --- a/docs/adr/template.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -status: proposed -date: YYYY-MM-DD -scope: back-end -tags: [architecture] ---- - -# ADR-NNNN: Short title in the imperative - - - -## Context - -What problem are we solving? What forces are at play (technical, business, team, licensing)? Link to the product documentation in `docs/product/` when relevant, citing rule IDs. - -## Decision - -What we will do, stated clearly and specifically enough to be implemented and reviewed. - -## Alternatives considered - -- **Option A.** Why it was not chosen. -- **Option B.** Why it was not chosen. - -## Consequences - -What becomes easier, what becomes harder, and what follow-up work this decision creates. - -## References - -Optional links to documentation or discussions. diff --git a/docs/agents/README.md b/docs/agents/README.md index 1c4e7d7..45e26a1 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -28,7 +28,7 @@ Work on an issue goes through three commands, each one a skill in `.claude/skill | Procedure | Use it to | |---|---| | [git-and-pull-requests.md](workflows/git-and-pull-requests.md) | Deliver any change through a branch and a pull request | -| [record-a-decision.md](workflows/record-a-decision.md) | Change an ADR, while the ADRs exist | +| [record-a-decision.md](workflows/record-a-decision.md) | Add or change an architecture decision | | [review-dependency-updates.md](workflows/review-dependency-updates.md) | Evaluate and merge Dependabot pull requests | ## For the owner diff --git a/docs/agents/guides/architecture.md b/docs/agents/guides/architecture.md index d47c8bc..332f359 100644 --- a/docs/agents/guides/architecture.md +++ b/docs/agents/guides/architecture.md @@ -1,6 +1,6 @@ # Architecture guide -The back-end follows Clean Architecture with tactical DDD. The decisions and their reasons are in the ADRs; this guide tells you where code goes. +The back-end follows Clean Architecture with tactical DDD. The decisions and their reasons are in the [decisions document](../../decisoes-de-arquitetura.md) (in Portuguese); this guide tells you where code goes. ## Projects and the dependency rule @@ -13,12 +13,12 @@ Domain ◄── Application ◄── Infrastructure | Project | Contains | May reference | |---|---|---| | `ControlService.Domain` | Aggregates, entities, value objects, domain services, domain errors, `ScreenKeys` | Nothing. No NuGet packages, no EF Core, no ASP.NET Core. | -| `ControlService.Application` | Commands and queries with their handlers, validators, request and response models, interfaces for infrastructure (repositories, `IEmailSender`, `ICurrentUser`) | Domain | -| `ControlService.Infrastructure` | EF Core `DbContext`, entity configurations, migrations, repository implementations, Identity, e-mail sending | Application, Domain | +| `ControlService.Application` | Commands and queries with their handlers, validators, request and response models, interfaces for infrastructure (repositories, `ICurrentUser`) | Domain | +| `ControlService.Infrastructure` | EF Core `DbContext`, entity configurations, migrations, repository implementations, Identity | Application, Domain | | `ControlService.API` | Minimal API endpoints, authentication and authorization setup, Problem Details mapping, `Program.cs` | Application; Infrastructure only in `Program.cs` | | `ControlService.AppHost`, `ControlService.ServiceDefaults` | Local orchestration and telemetry | API | -`tests/ControlService.ArchitectureTests` enforces these rules. If a test fails, move the code; never relax the test without a new ADR. +`tests/ControlService.ArchitectureTests` enforces these rules. If a test fails, move the code; never relax the test without the owner's approval (decision 28). ## Feature folders @@ -35,7 +35,7 @@ Infrastructure/Persistence/ AppDbContext.cs, Configurations/UserConfigurati Current features: `Access`, `Auth`, `Users`, `PermissionProfiles`, `Screens`. Shared building blocks (`Result`, `Error`, base types) go in a `Common` folder. -## Domain model (ADR-0006) +## Domain model (decision 3) - `User` and `PermissionProfile` are aggregate roots. They reference each other **by id only**. - Expose behavior, not setters: `user.Deactivate(by, now)`, `profile.SetLevel(screen, level)`. The aggregate protects its invariants (for example, a system record cannot change). @@ -44,36 +44,36 @@ Current features: `Access`, `Auth`, `Users`, `PermissionProfiles`, `Screens`. Sh - Effective access is a domain service: the highest level among the user's profiles, screen by screen (PERM-05, PERM-06 in `docs/product/features/permission-profiles.md`). - Rules that need data from other aggregates (uniqueness of login, profile in use) are checked in the handler and guaranteed by database constraints. -## Application layer (ADR-0007, ADR-0009) +## Application layer (decisions 5 to 7) - One folder per use case with its command or query, handler and validator. -- **Validation (ADR-0008):** each command has a FluentValidation `{UseCase}Validator` that checks shape and format (required fields, lengths, formats), with the Portuguese messages of the feature document. `ValidatingCommandHandler` (`Application/Common`) runs it before the handler and turns the failures into a `validation_failed` error whose `Fields` use camelCase paths (`emergencyContact.phone`). Handlers are registered by hand, each wrapped in the decorator (no Scrutor). Rules that need the database (uniqueness) run in the handler; business invariants stay in the domain. +- **Validation (decision 6):** each command has a FluentValidation `{UseCase}Validator` that checks shape and format (required fields, lengths, formats), with the Portuguese messages of the feature document. `ValidatingCommandHandler` (`Application/Common`) runs it before the handler and turns the failures into a `validation_failed` error whose `Fields` use camelCase paths (`emergencyContact.phone`). Handlers are registered by hand, each wrapped in the decorator (no Scrutor). Rules that need the database (uniqueness) run in the handler; business invariants stay in the domain. - **Field errors from the handler:** a uniqueness failure, or a value object that refuses a value, must reach the front-end under its field (API-13, CNV-18). Value objects return `validation_failed` without a field, so the handler returns a new `Error` with `Fields` set (`["cpf"] = [message]`); never pass a field-less `validation_failed` on to the API. -- **Mapping (ADR-0010):** hand-written extension methods next to each feature's models, such as `user.ToResponse()` and `request.ToCommand()`. No AutoMapper; Mapperly only if mapping becomes repetitive, after asking the owner. +- **Mapping (decision 8):** hand-written extension methods next to each feature's models, such as `user.ToResponse()` and `request.ToCommand()`. No AutoMapper; Mapperly only if mapping becomes repetitive, after asking the owner. - Handlers implement the in-house `ICommandHandler` / `IQueryHandler` (`Application/Common`), which return `Task>`. MediatR is not used. - Expected failures return `Result` / `Result` with an `Error` (stable `code`, Portuguese `message`, optional `Fields` and `Details`). Exceptions are only for unexpected failures. - Handlers orchestrate; business decisions live in the domain. - Queries may project straight to response models with `AsNoTracking`. -## API layer (ADR-0003, ADR-0020) +## API layer (decisions 4 and 24) - One `{Feature}Endpoints.cs` per feature, with an extension method `Map{Feature}Endpoints` on the `/api/v1` group. - Endpoints are thin: bind the request, call the handler, translate the `Result` to typed results and Problem Details. A failure becomes `error.ToProblem()` (`API/Common/ErrorResults.cs`), a `ProblemHttpResult`, so endpoints declare `Results, ProblemHttpResult>`. -- `ErrorStatusCodes` maps every error code of ADR-0009 to its status. A new code needs a new row and a case in `ErrorResultsTests`; an unmapped code throws, and the exception handler answers with the generic 500 (API-12, API-14). -- `account_inactive` maps to 401, its meaning during a session. The sign-in endpoint returns the 403 of that code explicitly (ADR-0009). +- `ErrorStatusCodes` maps every error code of the table in `docs/api/conventions.md#error-codes` to its status. A new code needs a new row and a case in `ErrorResultsTests`; an unmapped code throws, and the exception handler answers with the generic 500 (API-12, API-14). +- `account_inactive` maps to 401, its meaning during a session. The sign-in endpoint returns the 403 of that code explicitly. - Every endpoint declares its permission: `.RequireScreenAccess(ScreenKeys.Users, AccessLevel.Editor)`, with the minimum level from the feature document's *Operations* table (PERM-03). - Routes, status codes and error codes must match the feature document and `docs/api/conventions.md` exactly: the front-end depends on them. -## Persistence (ADR-0011 to ADR-0015) +## Persistence (decisions 10 to 14) - PostgreSQL through EF Core; `snake_case` names (`EFCore.NamingConventions`); ids are `Guid.CreateVersion7()` generated by the server. - `Domain/Common/AuditedAggregate` is the abstract base of every aggregate: `CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy` and `Version` (all private-set, filled by infrastructure). `User` and `PermissionProfile` inherit it. -- Concurrency token: `Version` (`uint`), mapped with `.IsRowVersion()` to PostgreSQL's `xmin` system column — no extra column. Conflicts return 409 `concurrency_conflict`, translated by `Infrastructure/Persistence/UnitOfWork` (`Application/Common/IUnitOfWork`), which names the record's latest editor (ADR-0014). +- Concurrency token: `Version` (`uint`), mapped with `.IsRowVersion()` to PostgreSQL's `xmin` system column — no extra column. Conflicts return 409 `concurrency_conflict`, translated by `Infrastructure/Persistence/UnitOfWork` (`Application/Common/IUnitOfWork`), which names the record's latest editor (decision 13). - Audit fields are filled by `AuditFieldsInterceptor` (an EF Core `SaveChangesInterceptor`) using `ICurrentUser` and `TimeProvider`, never by handlers. On insert, `updatedAt`/`updatedBy` get the same values as `createdAt`/`createdBy` (CNV-19). A change to an owned collection only (a profile's levels, a user's profiles) also counts as a change to the aggregate: the interceptor marks its `updatedAt`/`updatedBy` as modified, which forces an `UPDATE` of the owner row, so `xmin` changes and is checked (CNV-12, CNV-13). A save during a request with no signed-in user throws; outside a request (startup, seeding) `ICurrentUser.UserId` answers the Admin's id instead of `null`, so the seeder's own inserts are authored by the Admin (CNV-20). - Uniqueness is guaranteed by unique indexes (login, normalized display name, normalized profile name). -- `SystemRecordsSeeder` inserts the Admin, its credential and the Gerenciador profile only when missing (the credential is checked apart from the Admin row, so databases created before authentication get it), through EF Core's `UseSeeding`/`UseAsyncSeeding` (ADR-0022), which run as part of `Database.Migrate`/`MigrateAsync`. +- `SystemRecordsSeeder` inserts the Admin, its credential and the Gerenciador profile only when missing (the credential is checked apart from the Admin row, so databases created before authentication get it), through EF Core's `UseSeeding`/`UseAsyncSeeding` (decision 16), which run as part of `Database.Migrate`/`MigrateAsync`. -## Authentication (ADR-0019, ADR-0032, ADR-0023) +## Authentication (decisions 18 to 22) - **Application/Auth** has one folder per use case (`SignIn`, `RefreshSession`, `SignOut`, `ChangePassword`, `GetMe`) and the ports they use: `ICredentialStore` (password check with lockout, mandatory-change flag, replace password), `ISessionStore` (start, rotate, end one, end all) and `IAccessTokenIssuer`. `AuthErrors` holds every authentication error with its Portuguese message; `AuthSettings` carries the two configured values messages state (minimum password length, lockout minutes), because Application cannot read options. - **Identity** (`Infrastructure/Auth`): `AppDbContext` inherits `IdentityUserContext` (no roles). Its own `Users` set is the credentials; the domain users hide it under the same name, and credentials are reached through `Set()`. `UserCredential.Id` is the user's id, `UserName` is that id too (so a login change needs no syncing), and `MustChangePassword` marks the initial password. There is no `SignInManager` (it pulls in cookie authentication): `CredentialStore` uses `UserManager` for hashing and lockout only. @@ -82,5 +82,5 @@ Current features: `Access`, `Auth`, `Users`, `PermissionProfiles`, `Screens`. Sh - **Access token** (HS256, `API/Auth/JwtAccessTokenIssuer`): `sub` (user id, read by `HttpCurrentUser`), `sid` (session id, used by sign-out because the refresh cookie never reaches it), `iat`, `exp`, and `must_change_password` only while the change is mandatory. `MapInboundClaims` is off so the claim names stay as issued. Configuration is split by layer over the same `Auth` section: `AuthOptions` (Infrastructure) and `JwtOptions` (API). - **`MapApiV1()`** (`API/Common/ApiV1Group.cs`) builds the `/api/v1` group for `Program.cs` and the test factories: it requires authorization and adds `PasswordChangeRequiredFilter`, which answers 401 `password_change_required` while the token carries `must_change_password`. Endpoints opt out with `.AllowAnonymous()` (sign-in, refresh) or `.AllowPendingPasswordChange()` (`me`, sign-out, change-password). - **Rate limiting** (`API/Auth/AuthRateLimiting`): a sliding window of 60 seconds in 6 segments per client address on sign-in and refresh; a refused request answers 429 `locked_out` with `Retry-After`. -- **Not yet covered:** the per-request account check and permission cache (#9; it must also cover `me`, sign-out and change-password), revoking sessions on deactivation (AUTH-21, #11), activation and reset links (#12) and the global rate limit of ADR-0023. +- **Not yet covered:** the per-request account check and the per-endpoint permission filters (#9; the account check must also cover `me`, sign-out and change-password) and revoking sessions on deactivation (AUTH-21, #11). Not planned for now: a permission cache, activation and reset links by e-mail, and a global rate limit (decisions 21, 22 and 24). diff --git a/docs/agents/guides/coding-conventions.md b/docs/agents/guides/coding-conventions.md index 9be60f9..64c0008 100644 --- a/docs/agents/guides/coding-conventions.md +++ b/docs/agents/guides/coding-conventions.md @@ -6,8 +6,8 @@ The compiler enforces most of these: `TreatWarningsAsErrors`, `AnalysisLevel=lat | Artifact | Language | |---|---| -| Identifiers, code comments, commit messages, pull requests, ADRs, agent guides | English | -| Documentation in `docs/` (except the owner's guide) | English | +| Identifiers, code comments, commit messages, pull requests, agent guides | English | +| Documentation in `docs/` (except the owner's guide and the decisions document) | English | | User-facing messages, e-mail templates | Portuguese (pt-BR) | | Wire values that already exist in the front-end contract (`negado`, `leitor`, `editor`, `gerenciador`, screen keys) | Keep exactly as documented | @@ -41,9 +41,9 @@ Translate business terms exactly as in the [domain glossary](../../product/gloss ## Packages - Versions exist only in `backend/ControlService/Directory.Packages.props`. In a `.csproj`, write ``. -- Before adding a package, check whether an ADR already chose one for that purpose. A new dependency with architectural impact needs an ADR. +- Before adding a package, check whether the [decisions document](../../decisoes-de-arquitetura.md) already chose one for that purpose. A new dependency with architectural impact needs the owner's approval. - Never add packages to `ControlService.Domain`. -- Avoid libraries with commercial licenses for this project, such as MediatR 13+ (ADR-0007), FluentAssertions 8+ (ADR-0024) and AutoMapper, which has had a commercial edition since July 2025 (ADR-0010). +- Avoid libraries with commercial licenses for this project, such as MediatR 13+, FluentAssertions 8+ and AutoMapper, which have had commercial editions since 2025 (decisions 5 and 8). ## Suppressing a diagnostic diff --git a/docs/agents/guides/communication.md b/docs/agents/guides/communication.md index 7e36529..39f8e50 100644 --- a/docs/agents/guides/communication.md +++ b/docs/agents/guides/communication.md @@ -38,4 +38,4 @@ Apply each rule **literally**. When the code would do more or less than the docu ## Actions that need the owner's explicit confirmation -Publishing or changing anything on GitHub beyond the normal pull request flow and the issue body written by `/levantar-issue` (repository settings, rulesets, archiving, releases), deleting data (Docker volumes, branches, records), adding a dependency with architectural impact, and any change to an Accepted ADR. Machine security settings (certificates, firewall) are never changed by agents: give the owner the command. +Publishing or changing anything on GitHub beyond the normal pull request flow and the issue body written by `/levantar-issue` (repository settings, rulesets, archiving, releases), deleting data (Docker volumes, branches, records), adding a dependency with architectural impact, and any change to an architecture decision. Machine security settings (certificates, firewall) are never changed by agents: give the owner the command. diff --git a/docs/agents/guides/documentation.md b/docs/agents/guides/documentation.md index 2046268..d64cfc0 100644 --- a/docs/agents/guides/documentation.md +++ b/docs/agents/guides/documentation.md @@ -13,7 +13,7 @@ Documentation is part of the change. A pull request that changes behavior, the A | A screen, area or screen key | `backend` `ScreenKeys`, `docs/product/screen-catalog.json` and the navigation tables in `docs/product/overview.md` | | Routes, paging, versions or error codes shared by all endpoints | `docs/api/conventions.md` | | An open question is raised or answered | `docs/product/open-questions.md` (answered ones move to "Resolved") | -| A technical decision | A new or superseding ADR in `docs/adr/`, plus the index ([record a decision](../workflows/record-a-decision.md)) | +| A technical decision | `docs/decisoes-de-arquitetura.md` ([record a decision](../workflows/record-a-decision.md)) | | The front-end prototype | `docs/frontend/` | | How to run, build or test | `backend/ControlService/README.md` and, if user-visible, the root `README.md` | | Roadmap progress | The checklist in the root `README.md` | @@ -21,11 +21,11 @@ Documentation is part of the change. A pull request that changes behavior, the A ## Rules -- Everything is in **English**, except user-facing messages (Portuguese, verbatim), wire values, the owner's guide `docs/agents/trabalhando-com-agentes.md`, and what the three commands produce for the owner: issue bodies and reports. +- Everything is in **English**, except user-facing messages (Portuguese, verbatim), wire values, the owner's guide `docs/agents/trabalhando-com-agentes.md`, the decisions document `docs/decisoes-de-arquitetura.md`, and what the three commands produce for the owner: issue bodies and reports. - `docs/product/` describes **decided** behavior. Open questions go to `docs/product/open-questions.md`, never presented as rules. - **Rule IDs are stable**: a new rule takes the next free number; a removed rule is marked *Retired*, never deleted or renumbered. Changing a rule's meaning needs the owner's confirmation, and the pull request lists the IDs that changed. - **One fact in one place.** Link to the document that owns a fact instead of repeating it. - If the code and `docs/` disagree, stop and ask the owner which one is right. Do not "fix" the documentation to match the code silently. - Keep relative links working. When you rename a file or a heading, search for links to it. -- ADRs follow [record-a-decision.md](../workflows/record-a-decision.md). Accepted ADRs are never rewritten, only superseded. +- Architecture decisions follow [record-a-decision.md](../workflows/record-a-decision.md). Changing one needs the owner's approval. - The OpenAPI document is generated from the endpoints. Endpoint summaries and descriptions are in English; examples of error messages stay in Portuguese. diff --git a/docs/agents/guides/local-environment.md b/docs/agents/guides/local-environment.md index 42f0671..eb87f3a 100644 --- a/docs/agents/guides/local-environment.md +++ b/docs/agents/guides/local-environment.md @@ -55,20 +55,19 @@ Use these names exactly; do not invent others. Register the API side with the Aspire client integrations for these names, and check the exact method names and connection string format in the current Aspire documentation when you implement them. Integration tests start their own containers with Testcontainers and pass the connection strings under the same names, without the AppHost. -**Configuration keys** (from the ADRs): +**Configuration keys:** | Key | Meaning | Default | Where the value lives | |---|---|---|---| -| `Auth:AccessTokenMinutes` | Access token lifetime (ADR-0032) | 15 | `appsettings.json` | -| `Auth:RefreshTokenIdleHours` | Sliding refresh token lifetime (ADR-0032) | 8 | `appsettings.json` | -| `Auth:SigningKey` | JWT signing key: the base64 of at least 32 random bytes (ADR-0019) | none | **User secrets** | +| `Auth:AccessTokenMinutes` | Access token lifetime | 15 | `appsettings.json` | +| `Auth:RefreshTokenIdleHours` | Sliding refresh token lifetime | 8 | `appsettings.json` | +| `Auth:SigningKey` | JWT signing key: the base64 of at least 32 random bytes | none | **User secrets** | | `Auth:Issuer`, `Auth:Audience` | JWT issuer and audience | `control-service` | `appsettings.json` | | `Auth:PasswordMinLength` | Minimum password length (AUTH-16) | 8 | `appsettings.json` | | `Auth:LockoutMaxFailedAttempts`, `Auth:LockoutMinutes` | Consecutive failures that lock a login, and for how long (AUTH-08) | 5 and 15 | `appsettings.json` | -| `RateLimiting:SignIn:PermitLimit`, `RateLimiting:Refresh:PermitLimit` | Requests per address in 60 seconds (ADR-0023, AUTH-26) | 20 and 60 | `appsettings.json` | -| `Admin:Email` | E-mail of the seeded Admin (ADR-0022) | none | **User secrets** | +| `RateLimiting:SignIn:PermitLimit`, `RateLimiting:Refresh:PermitLimit` | Requests per address in 60 seconds (AUTH-26) | 20 and 60 | `appsettings.json` | +| `Admin:Email` | E-mail of the seeded Admin | none | **User secrets** | | `Admin:InitialPassword` | Initial Admin password, changed on first access (AUTH-13); at least `Auth:PasswordMinLength` characters | none | **User secrets** | -| `Email:SendTimeoutSeconds` | Timeout for activation e-mails (ADR-0031) | 10 | `appsettings.json` | - **Secrets live only in the user secrets of the API project** (`UserSecretsId` is in `ControlService.API.csproj`) and, in deployment, in environment variables. Never in `appsettings*.json`, code, tests or documentation. - The API must fail at startup with a clear message when a required secret is missing, instead of running with an empty value. @@ -107,7 +106,7 @@ dotnet ef migrations add --project src/ControlService.Infrastructure --st This uses `DesignTimeDbContextFactory`, not `Program.cs`, so it never needs a running database or Aspire: it only inspects the model. The schema has two migrations: `InitialSchema` and `AddAuthentication` (Identity's credential tables and `user_sessions`, approved in #8). From here on, each schema change adds a migration; review it before committing, since a migration has no Red of its own. -Migrations are applied automatically only when `ASPNETCORE_ENVIRONMENT` is `Development` (ADR-0013), which also runs `SystemRecordsSeeder` (ADR-0022). Never in other environments; a deployment step applies them there instead. +Migrations are applied automatically only when `ASPNETCORE_ENVIRONMENT` is `Development`, which also runs `SystemRecordsSeeder`. Never in other environments; a deployment step applies them there instead. ## HTTPS diff --git a/docs/agents/guides/testing.md b/docs/agents/guides/testing.md index 7c6e25f..6db6bc6 100644 --- a/docs/agents/guides/testing.md +++ b/docs/agents/guides/testing.md @@ -1,14 +1,14 @@ # Testing guide -The strategy is in ADR-0024, and all development is test-first. Tests are the executable version of the business rules, so they are part of every change, not an extra. Each issue carries a numbered test list ([AGENTS.md](../../../AGENTS.md#tests)); **how** to write the tests, one failing test at a time, is in the [`/executar-issue` skill](../../../.claude/skills/executar-issue/SKILL.md). +The strategy is in decisions 26 to 28 of the [decisions document](../../decisoes-de-arquitetura.md) (in Portuguese), and all development is test-first. Tests are the executable version of the business rules, so they are part of every change, not an extra. Each issue carries a numbered test list ([AGENTS.md](../../../AGENTS.md#tests)); **how** to write the tests, one failing test at a time, is in the [`/executar-issue` skill](../../../.claude/skills/executar-issue/SKILL.md). ## Test projects | Project | Tests | Doubles | |---|---|---| | `ControlService.Domain.Tests` | Value objects, aggregates, effective access | None: the domain has no dependencies | -| `ControlService.Application.Tests` | Handlers and validators | Hand-written in-memory fakes of the Application interfaces; NSubstitute only when a fake would be clearly heavier | -| `ControlService.Api.IntegrationTests` | Real HTTP calls, authentication, authorization, persistence; also unit tests of the API's own helpers, such as the `Error` → Problem Details table (`Common/ErrorResultsTests.cs`), since there is no API unit test project | `WebApplicationFactory`, Testcontainers (PostgreSQL, Mailpit) | +| `ControlService.Application.Tests` | Handlers and validators | In-memory fakes of the Application interfaces, classes of the test project (`Fakes/`); no mock library | +| `ControlService.Api.IntegrationTests` | Real HTTP calls, authentication, authorization, persistence; also unit tests of the API's own helpers, such as the `Error` → Problem Details table (`Common/ErrorResultsTests.cs`), since there is no API unit test project | `WebApplicationFactory`, Testcontainers (PostgreSQL) | | `ControlService.ArchitectureTests` | Dependency rules between layers | None | Framework: xUnit v3 on Microsoft.Testing.Platform, assertions with Shouldly. Common packages come from `tests/Directory.Build.props`; do not repeat them in each project. @@ -52,13 +52,13 @@ dotnet test --solution ControlService.slnx --coverage --coverage-output-format c - Integration tests run against real containers, never the EF Core in-memory provider. - Pass `TestContext.Current.CancellationToken` to async calls (xUnit analyzer rule). -## Mandatory cases (ADR-0024) +## Mandatory cases Effective permission: `Denied` in one profile and `Editor` in another gives `Editor`; a screen missing from a profile counts as `Denied`; the Gerenciador profile gives `Manager` on every screen, including new ones; an unknown profile id is ignored; no profiles gives `Denied` everywhere. -Account flows: activation link sent on creation; link expired after 72 hours; link invalid after use or after "resend access"; identical response to password-reset requests for existing and unknown logins. +Account flows: an unknown login and a wrong password get the same error; a refresh token works once, and the old one stops working; a session ends after 8 hours without action; changing the password ends every session; while the password change is mandatory, every endpoint except `me`, sign-out and change-password answers 401 `password_change_required`; no password, token or cookie reaches a log. -Every endpoint: one allowed and one denied path for its minimum level; the 409 concurrency path for updates. +Every endpoint, from issue #9 on: one allowed and one denied path for its minimum level; the 409 concurrency path for updates. ## API tests without a feature endpoint @@ -68,7 +68,7 @@ Every endpoint: one allowed and one denied path for its minimum level; the 409 c ## Persistence tests -Since `Program` needs a database to start, **every** `WebApplicationFactory` in `ControlService.Api.IntegrationTests` shares one PostgreSQL container: `Common/PostgresContainerFixture.cs` is an xUnit v3 **assembly fixture** (`[assembly: AssemblyFixture(...)]`), started once and stopped after the whole assembly runs. `Common/ApiFactory.cs` is the shared base every factory derives from (including `ErrorEndpointsFactory`); it wires the container's connection string as `ConnectionStrings:controlservice` and `Admin:Email` = `admin@example.com`, and forces `Development` so migrations and seeding run (ADR-0013). This needs **Docker running locally and in CI**. +Since `Program` needs a database to start, **every** `WebApplicationFactory` in `ControlService.Api.IntegrationTests` shares one PostgreSQL container: `Common/PostgresContainerFixture.cs` is an xUnit v3 **assembly fixture** (`[assembly: AssemblyFixture(...)]`), started once and stopped after the whole assembly runs. `Common/ApiFactory.cs` is the shared base every factory derives from (including `ErrorEndpointsFactory`); it wires the container's connection string as `ConnectionStrings:controlservice` and `Admin:Email` = `admin@example.com`, and forces `Development` so migrations and seeding run. This needs **Docker running locally and in CI**. Because every factory targets the same database, test collections run **sequentially** (`xunit.runner.json`, `parallelizeTestCollections: false`): two hosts racing to seed the system records at the same time would violate `pk_users`. `Xunit.CollectionBehaviorAttribute.DisableTestParallelization` is obsolete in xunit v3; use the JSON setting instead. @@ -85,5 +85,5 @@ Because every factory targets the same database, test collections run **sequenti - **The Admin is one row shared by the whole test assembly**, so a test that signs in as the Admin or changes its password starts with `AuthTestSupport.ResetAdminAsync` (initial password, mandatory change, no sessions, no activation time). `AuthTestSupport.CreateUserAsync` creates an active user with a credential under a unique login; `GrantAsync` and `DeactivateUserAsync` change what that user may do. - **Any other authenticated endpoint** is stood in for by `/api/v1/test-only/protected`, mapped through `MapApiV1()` by a startup filter (that filter needs its own `UseRouting`, `UseAuthentication` and `UseAuthorization`). - `Auth/CapturingLoggerProvider` keeps every log line, prefixed by its level, so a test can assert that no password, token or cookie value was written. To capture below `Information`, add a filter rule for the provider (`AddFilter(null, LogLevel.Trace)`): `SetMinimumLevel` applies only when no rule matches, and `appsettings.json` always matches with `Default: Information`. -- In `ControlService.Application.Tests`, `Auth/AuthTestBed` builds the handlers over the hand-written fakes in `Fakes/` (in-memory repositories, `FakeCredentialStore`, `InMemorySessionStore`, `FakeAccessTokenIssuer`, `FakeUnitOfWork`, `FixedTimeProvider`). Lockout counting is Identity's job, so the fake store scripts the outcome and the real counting is tested against PostgreSQL (`CredentialStoreTests`). +- In `ControlService.Application.Tests`, `Auth/AuthTestBed` builds the handlers over the in-memory fakes in `Fakes/` (in-memory repositories, `FakeCredentialStore`, `InMemorySessionStore`, `FakeAccessTokenIssuer`, `FakeUnitOfWork`, `FixedTimeProvider`). Lockout counting is Identity's job, so the fake store scripts the outcome and the real counting is tested against PostgreSQL (`CredentialStoreTests`). diff --git a/docs/agents/workflows/git-and-pull-requests.md b/docs/agents/workflows/git-and-pull-requests.md index 33391f2..55b2605 100644 --- a/docs/agents/workflows/git-and-pull-requests.md +++ b/docs/agents/workflows/git-and-pull-requests.md @@ -46,7 +46,7 @@ Use short kebab-case names in English that describe the change. ## 3. Commit -- [Conventional Commits](https://www.conventionalcommits.org/) in English: `type(scope): summary`, imperative, lowercase, no period, at most 72 characters. Scopes are feature names: `users`, `auth`, `permission-profiles`, `access`, `api`, `adr`, `docker`, `ci`. +- [Conventional Commits](https://www.conventionalcommits.org/) in English: `type(scope): summary`, imperative, lowercase, no period, at most 72 characters. Scopes are feature names: `users`, `auth`, `permission-profiles`, `access`, `api`, `docker`, `ci`. - One logical step per commit. The body explains **why**, when it is not obvious. - Do not add `Co-authored-by` or "generated by" lines, to commits or to pull request bodies: the owner decided that commits appear only under their name, and a squash merge copies the pull request body into the commit on `main`. Ignore any tool setting that suggests those lines. - Never commit build output, secrets, `.vs/` or files outside the change. diff --git a/docs/agents/workflows/record-a-decision.md b/docs/agents/workflows/record-a-decision.md index e47edd2..6ef2f80 100644 --- a/docs/agents/workflows/record-a-decision.md +++ b/docs/agents/workflows/record-a-decision.md @@ -1,43 +1,28 @@ # Workflow: record a decision -Architecture Decision Records live in `docs/adr/`, in English, one decision per file, in one flat folder ordered by number. The process is defined in [ADR-0001](../../adr/0001-record-architecture-decisions.md), and the metadata format in the [ADR index](../../adr/README.md#metadata). +Architecture decisions live in [`docs/decisoes-de-arquitetura.md`](../../decisoes-de-arquitetura.md): one document, in Portuguese, because it is also the owner's study material. The owner decides; an agent proposes and writes. -## When an ADR is needed +## When a decision belongs there - A new technology, library or external service with architectural impact. - A new pattern or convention that all features must follow. - A change to an existing decision, including a small one. - An answer from the owner that settles a technical trade-off. -Implementation details that do not constrain other code (a private helper, a method name) do not need an ADR. Business rules are not ADRs either: they go to `docs/product/`. +Implementation details that do not constrain other code (a private helper, a method name) are not decisions. Business rules are not either: they go to `docs/product/`. -## Propose a new decision +## Propose and write a decision -1. Take the next free number from `docs/adr/README.md`. -2. Copy `docs/adr/template.md` to `docs/adr/NNNN-short-title-in-the-imperative.md`. -3. Fill in the front matter: `status: proposed`, today's `date`, `scope` and `tags` from the vocabulary in the index. -4. Fill in context, decision, alternatives considered (with the reason each was rejected) and consequences. Link the product documents it relates to and cite rule IDs. -5. Add it to the index in `docs/adr/README.md`, in the matching category. -6. Present it to the owner in Portuguese: the problem, the options with trade-offs and your recommendation. +1. Explain it to the owner first, in Portuguese, in the six points of [AGENTS.md](../../../AGENTS.md#the-owner): what is proposed, which problem it solves, what happens without it, what it costs, the simpler alternatives, and what you recommend and why. +2. Only after the owner decides, write it in the document, in the format of the others: the six numbered points, then **Onde ver no código** and **Em uma frase**. Put it in the block it belongs to, with the next free number. Never renumber: other documents cite decisions by number. +3. If the owner decides **not** to adopt something, add a row to "O que ficou de fora" with the reason. +4. If the code does not do it yet, say so in the text, as the document does with "Ainda não está construído", and list any pending cleanup in the table at the start of the block. +5. If the decision answers an item in `docs/product/open-questions.md`, move the item to "Resolved". -## Accept a decision +## Change a decision -Only the owner accepts a decision. When they confirm: - -1. In the front matter, set `status: accepted` and add `accepted: YYYY-MM-DD`. -2. Update the status column and the count of accepted records in `docs/adr/README.md`. -3. If it answers an item in `docs/product/open-questions.md`, move the item to "Resolved" with a link to the ADR. - -A *proposed* ADR may still be edited. An **accepted** ADR is never rewritten. - -## Change an accepted decision - -1. Write a new ADR that explains what changes and why. In its front matter, add `supersedes: ADR-NNNN`, or `supersedes: ADR-NNNN ( only)` when only part of it changes. Use `amends` when the new record completes the old one without replacing anything. -2. In the old ADR, change **only the front matter**: `status: superseded` with `superseded-by: ADR-MMMM` for a full replacement; for a partial one, keep `status: accepted` and add `superseded-by: ADR-MMMM ( only)` or `amended-by: ADR-MMMM (...)`. -3. Update the index, and every document in `docs/product/` and `docs/api/` that described the old behavior. - -Maintenance that does not change the decision (fixing a broken link, a moved path or the metadata) is allowed in accepted records. +Edit it in place and keep the six points true. Record when and why it changed in the sixth point ("Reavaliada em AAAA-MM-DD: ..."), as the document already does. Update every document in `docs/product/` and `docs/api/` that described the old behavior. ## Deliver -ADR changes go through a pull request like any other change ([git and pull requests](git-and-pull-requests.md)), with the `docs(adr)` scope: `docs(adr): propose ADR-0033 for signature storage`. +A change goes through a pull request like any other ([git and pull requests](git-and-pull-requests.md)), with the `docs` type: `docs: record the decision on signature storage`. diff --git a/docs/agents/workflows/review-dependency-updates.md b/docs/agents/workflows/review-dependency-updates.md index fba0362..beb7a3f 100644 --- a/docs/agents/workflows/review-dependency-updates.md +++ b/docs/agents/workflows/review-dependency-updates.md @@ -28,7 +28,7 @@ Dependabot opens pull requests every Monday for NuGet packages (grouped as `aspi |---|---| | Security update, CI green | Merge soon; say which vulnerability it fixes | | Patch or minor, CI green | Safe to merge | - | Major version, or a package that changed its license | Read the release notes and the license; summarize breaking changes and whether an ADR is affected before recommending | + | Major version, or a package that changed its license | Read the release notes and the license; summarize breaking changes and whether a decision in the [decisions document](../../decisoes-de-arquitetura.md) is affected before recommending | | CI red | Investigate the failure on the Dependabot branch; propose a fix in a separate pull request if code must change | 5. Report to the owner in Portuguese, one line per pull request, with your recommendation. **Merge only the pull requests the owner explicitly approves:** @@ -41,4 +41,4 @@ Dependabot opens pull requests every Monday for NuGet packages (grouped as `aspi - Never pin a package to an older version or ignore an update without telling the owner why. - A license change to a commercial model (as happened with MediatR, AutoMapper and FluentAssertions) is a blocker: do not merge; raise it with the owner. -- If `global.json` or the .NET SDK major version changes, treat it as a decision (ADR-0002) and ask the owner. +- If `global.json` or the .NET SDK major version changes, treat it as a decision (decision 1) and ask the owner. diff --git a/docs/decisoes-de-arquitetura.md b/docs/decisoes-de-arquitetura.md index 74e2f60..75c65b2 100644 --- a/docs/decisoes-de-arquitetura.md +++ b/docs/decisoes-de-arquitetura.md @@ -15,6 +15,8 @@ No fim de cada uma há dois atalhos: **onde ver no código** e **em uma frase**, As regras de negócio não estão aqui: elas ficam em [product/](product/). Este documento trata só de *como* o sistema é construído. +Este documento substitui os 33 ADRs (registros de decisão de arquitetura) que existiam em `docs/adr/`. Eles continuam no histórico do git. + ## Bloco A — Estrutura e fluxo do código ### O caminho de uma requisição @@ -581,7 +583,6 @@ As decisões deste bloco descrevem o que está em uso. Estas sobras do plano ant | *Service discovery* e resiliência de HTTP, que vieram do modelo do Aspire | `ServiceDefaults/Extensions.cs` | Remover (decisão 31) | | Um pacote do Visual Studio em versão *preview*, que só serve para depurar dentro de um contêiner | `ControlService.API.csproj` | Remover (decisão 33) | | O CI não confere a formatação | `.github/workflows/ci.yml` | Acrescentar o passo (decisão 29) | -| O guia de testes cita o Mailpit e os casos de link de ativação | `docs/agents/guides/testing.md` | Corrigir junto com a troca das citações de ADR | ### 26. Testes em quatro projetos, cada comportamento em uma camada, contra PostgreSQL de verdade diff --git a/docs/product/features/template.md b/docs/product/features/template.md index f99622a..39e2772 100644 --- a/docs/product/features/template.md +++ b/docs/product/features/template.md @@ -3,7 +3,7 @@ One or two sentences: what this feature is for, in business terms. Link related features. - **Screen:** Name in the menu, key `area/screen` (from [screen-catalog.json](../screen-catalog.json)). -- **Decisions:** the ADRs that apply. +- **Decisions:** the numbers of the decisions in [decisoes-de-arquitetura.md](../../decisoes-de-arquitetura.md) that apply. - **Read with:** [conventions](../conventions.md), [API conventions](../../api/conventions.md), [glossary](../glossary.md). + net10.0 enable diff --git a/backend/ControlService/Directory.Packages.props b/backend/ControlService/Directory.Packages.props index 0e60e65..cde598a 100644 --- a/backend/ControlService/Directory.Packages.props +++ b/backend/ControlService/Directory.Packages.props @@ -1,5 +1,5 @@ - + true @@ -11,7 +11,7 @@ - + @@ -41,7 +41,7 @@ - + diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs index 4929ae4..d15e21a 100644 --- a/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs +++ b/backend/ControlService/src/ControlService.API/Auth/AuthRateLimiting.cs @@ -7,7 +7,7 @@ namespace ControlService.API.Auth; -/// Rate limits of the authentication routes (ADR-0023, D14, T9): a sliding window of 60 seconds +/// Rate limits of the authentication routes (D14, T9): a sliding window of 60 seconds /// in 6 segments per client address, with no queue. A refused request answers like a lockout does: /// 429 `locked_out` with `Retry-After` (AUTH-26). internal static class AuthRateLimiting diff --git a/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs index 27df179..617c493 100644 --- a/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs +++ b/backend/ControlService/src/ControlService.API/Auth/AuthServiceCollectionExtensions.cs @@ -27,7 +27,7 @@ public static IServiceCollection AddAuthFeature(this IServiceCollection services services.AddAuthorization(); services.AddAuthRateLimiting(configuration); - // Handlers are registered by hand (ADR-0008); the ones with input to validate are wrapped in the decorator. + // Handlers are registered by hand; the ones with input to validate are wrapped in the decorator. services.AddScoped, SignInHandler>(); services.AddScoped>(serviceProvider => new ValidatingCommandHandler( diff --git a/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs index f383b90..1d4f885 100644 --- a/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs +++ b/backend/ControlService/src/ControlService.API/Auth/JwtOptions.cs @@ -2,7 +2,7 @@ namespace ControlService.API.Auth; -/// The `Auth` keys that decide how access tokens are signed and checked (ADR-0019, ADR-0032). +/// The `Auth` keys that decide how access tokens are signed and checked. /// The keys Infrastructure reads live in the same section. public sealed class JwtOptions { diff --git a/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs index 110de10..1a4bb2e 100644 --- a/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs +++ b/backend/ControlService/src/ControlService.API/Auth/PasswordChangeRequiredFilter.cs @@ -5,7 +5,7 @@ namespace ControlService.API.Auth; /// While the Admin has not replaced the initial password, its access token carries /// `must_change_password` and every endpoint except the ones that opted out answers 401 -/// `password_change_required` (AUTH-14, ADR-0032, T8). +/// `password_change_required` (AUTH-14, T8). public sealed class PasswordChangeRequiredFilter : IEndpointFilter { public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -23,7 +23,7 @@ public sealed class PasswordChangeRequiredFilter : IEndpointFilter } /// Marks an endpoint the person may call before replacing the initial password: `me`, sign-out -/// and change-password (ADR-0032). +/// and change-password. public sealed record AllowPendingPasswordChangeMetadata; public static class PasswordChangeRequiredExtensions diff --git a/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs b/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs index 5493caa..17b590d 100644 --- a/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs +++ b/backend/ControlService/src/ControlService.API/Auth/RateLimitingOptions.cs @@ -1,6 +1,6 @@ namespace ControlService.API.Auth; -/// Requests per client address in a 60-second window (ADR-0023, AUTH-22). Sign-in and refresh +/// Requests per client address in a 60-second window (AUTH-22). Sign-in and refresh /// each have their own limit; the global limit and the e-mail routes come with their own issues. public sealed class RateLimitingOptions { diff --git a/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs b/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs index c32c666..608ef73 100644 --- a/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs +++ b/backend/ControlService/src/ControlService.API/Auth/RefreshCookie.cs @@ -1,6 +1,6 @@ namespace ControlService.API.Auth; -/// The refresh token cookie (ADR-0019, T6). Its `Path` is the refresh route only, so the +/// The refresh token cookie (T6). Its `Path` is the refresh route only, so the /// browser sends it nowhere else; expiring it needs the same name and `Path`. internal static class RefreshCookie { diff --git a/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs b/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs index 0d10465..18cad8f 100644 --- a/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs +++ b/backend/ControlService/src/ControlService.API/Common/ApiV1Group.cs @@ -2,13 +2,13 @@ namespace ControlService.API.Common; -/// The `/api/v1` group every feature registers its endpoints under (ADR-0003, API-01). +/// The `/api/v1` group every feature registers its endpoints under (API-01). /// `Program.cs` and the test factories both map it through here. public static class ApiV1Group { /// Every endpoint requires a signed-in person unless it opts out with `AllowAnonymous()`, and /// refuses a person who still has to replace the initial password unless it opts out with - /// `AllowPendingPasswordChange()` (AUTH-14, ADR-0032). + /// `AllowPendingPasswordChange()` (AUTH-14). public static RouteGroupBuilder MapApiV1(this IEndpointRouteBuilder app) => app.MapGroup("/api/v1").RequireAuthorization().AddEndpointFilter(); } diff --git a/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs b/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs index caf93ea..df2f26a 100644 --- a/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs +++ b/backend/ControlService/src/ControlService.API/Common/ErrorResults.cs @@ -5,18 +5,18 @@ namespace ControlService.API.Common; /// Translates a domain into the Problem Details response documented -/// in ADR-0009 and `docs/api/conventions.md#errors` (API-12, API-13). +/// in `docs/api/conventions.md#errors` (API-12, API-13). public static class ErrorResults { /// The error to translate. /// For the one code whose status depends on the endpoint: `account_inactive` - /// is a 403 at sign-in and a 401 during a session (ADR-0009). + /// is a 403 at sign-in and a 401 during a session. public static ProblemHttpResult ToProblem(this Error error, int? statusOverride = null) { // An unmapped code is a bug: the exception handler logs it and answers with the generic 500 (API-12). var status = statusOverride ?? ErrorStatusCodes.For(error.Code) ?? throw new InvalidOperationException( - $"The error code '{error.Code}' has no HTTP status in ErrorStatusCodes (ADR-0009)."); + $"The error code '{error.Code}' has no HTTP status in ErrorStatusCodes."); var (type, title) = ProblemTypes.For(status); var problemDetails = new ProblemDetails diff --git a/backend/ControlService/src/ControlService.API/Common/ErrorStatusCodes.cs b/backend/ControlService/src/ControlService.API/Common/ErrorStatusCodes.cs index df2a253..6ee6e89 100644 --- a/backend/ControlService/src/ControlService.API/Common/ErrorStatusCodes.cs +++ b/backend/ControlService/src/ControlService.API/Common/ErrorStatusCodes.cs @@ -1,8 +1,8 @@ namespace ControlService.API.Common; -/// The HTTP status of every error code in the ADR-0009 table. A code missing here is a -/// programming mistake, not a business outcome: treats it as an -/// unexpected failure (API-12, API-14). +/// The HTTP status of every error code in the table of docs/api/conventions.md#error-codes. +/// A code missing here is a programming mistake, not a business outcome: +/// treats it as an unexpected failure (API-12, API-14). internal static class ErrorStatusCodes { private static readonly Dictionary StatusesByCode = new() diff --git a/backend/ControlService/src/ControlService.API/Common/HttpCurrentUser.cs b/backend/ControlService/src/ControlService.API/Common/HttpCurrentUser.cs index 9c30ab3..dc74bae 100644 --- a/backend/ControlService/src/ControlService.API/Common/HttpCurrentUser.cs +++ b/backend/ControlService/src/ControlService.API/Common/HttpCurrentUser.cs @@ -3,7 +3,7 @@ namespace ControlService.API.Common; -/// ICurrentUser backed by the request's authenticated principal (ADR-0015, ADR-0019). +/// ICurrentUser backed by the request's authenticated principal. /// Outside a request (startup, seeding), there is no HttpContext at all, and the system acts /// as the Admin (CNV-20). public sealed class HttpCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser diff --git a/backend/ControlService/src/ControlService.API/Common/ProblemTypes.cs b/backend/ControlService/src/ControlService.API/Common/ProblemTypes.cs index 138bae0..8ea72be 100644 --- a/backend/ControlService/src/ControlService.API/Common/ProblemTypes.cs +++ b/backend/ControlService/src/ControlService.API/Common/ProblemTypes.cs @@ -1,6 +1,6 @@ namespace ControlService.API.Common; -/// The `type` and `title` pair for each status code this API returns (ADR-0009): RFC 9110, +/// The `type` and `title` pair for each status code this API returns: RFC 9110, /// and RFC 6585 for 429, which RFC 9110 does not define. internal static class ProblemTypes { diff --git a/backend/ControlService/src/ControlService.API/ControlService.API.csproj b/backend/ControlService/src/ControlService.API/ControlService.API.csproj index 9aeae7a..36557e4 100644 --- a/backend/ControlService/src/ControlService.API/ControlService.API.csproj +++ b/backend/ControlService/src/ControlService.API/ControlService.API.csproj @@ -1,6 +1,6 @@ - + a0e82690-8e8c-42bc-a8fe-3893a0f5a183 @@ -17,7 +17,7 @@ - + diff --git a/backend/ControlService/src/ControlService.API/ControlService.API.http b/backend/ControlService/src/ControlService.API/ControlService.API.http index a2fddee..bf2a7d9 100644 --- a/backend/ControlService/src/ControlService.API/ControlService.API.http +++ b/backend/ControlService/src/ControlService.API/ControlService.API.http @@ -1,6 +1,6 @@ @ControlService.API_HostAddress = https://localhost:7243 -# OpenAPI document generated from the endpoints (ADR-0004). +# OpenAPI document generated from the endpoints. # The interactive reference is at {{ControlService.API_HostAddress}}/scalar GET {{ControlService.API_HostAddress}}/openapi/v1.json Accept: application/json @@ -11,7 +11,7 @@ GET {{ControlService.API_HostAddress}}/health ### -# Authentication (ADR-0019). Replace the password placeholder with the value you stored in the user +# Authentication. Replace the password placeholder with the value you stored in the user # secrets as Admin:InitialPassword; never commit a real one. Paste the access token from the sign-in # response into @accessToken below. The refresh token travels in an HttpOnly cookie scoped to the refresh route. @accessToken = diff --git a/backend/ControlService/src/ControlService.API/Dockerfile b/backend/ControlService/src/ControlService.API/Dockerfile index 97c763d..91aa145 100644 --- a/backend/ControlService/src/ControlService.API/Dockerfile +++ b/backend/ControlService/src/ControlService.API/Dockerfile @@ -12,7 +12,7 @@ EXPOSE 8081 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -# Configurações comuns da solução (ADR-0026) e os projetos de que a API depende, para o restore ficar em cache +# Configurações comuns da solução e os projetos de que a API depende, para o restore ficar em cache COPY ["global.json", "Directory.Build.props", "Directory.Packages.props", ".editorconfig", "./"] COPY ["src/ControlService.API/ControlService.API.csproj", "src/ControlService.API/"] COPY ["src/ControlService.Application/ControlService.Application.csproj", "src/ControlService.Application/"] diff --git a/backend/ControlService/src/ControlService.API/Program.cs b/backend/ControlService/src/ControlService.API/Program.cs index b833fde..03d3552 100644 --- a/backend/ControlService/src/ControlService.API/Program.cs +++ b/backend/ControlService/src/ControlService.API/Program.cs @@ -33,7 +33,7 @@ app.MapOpenApi(); app.MapScalarApiReference(); - // Migrations run automatically only here (ADR-0013); MigrateAsync also runs the seeder (D2). + // Migrations run automatically only here; MigrateAsync also runs the seeder (D2). using var scope = app.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); } @@ -43,7 +43,7 @@ app.UseAuthentication(); app.UseAuthorization(); -// Every feature registers its endpoints under /api/v1 (ADR-0003), for example: api.MapUserEndpoints(); +// Every feature registers its endpoints under /api/v1, for example: api.MapUserEndpoints(); var api = app.MapApiV1(); api.MapAuthEndpoints(); diff --git a/backend/ControlService/src/ControlService.AppHost/AppHost.cs b/backend/ControlService/src/ControlService.AppHost/AppHost.cs index f63a0c7..d65229e 100644 --- a/backend/ControlService/src/ControlService.AppHost/AppHost.cs +++ b/backend/ControlService/src/ControlService.AppHost/AppHost.cs @@ -14,7 +14,7 @@ var database = postgres.AddDatabase("controlservice"); -// Test inbox: catches every e-mail the API sends (ADR-0030). Nothing reaches real people. +// Test inbox: catches every e-mail the API sends. Nothing reaches real people. var mailpit = builder.AddMailPit("mailpit") .WithContainerName($"{NamePrefix}-mailpit") .WithLifetime(ContainerLifetime.Persistent); diff --git a/backend/ControlService/src/ControlService.AppHost/ControlService.AppHost.csproj b/backend/ControlService/src/ControlService.AppHost/ControlService.AppHost.csproj index 5b722f2..fcd41ff 100644 --- a/backend/ControlService/src/ControlService.AppHost/ControlService.AppHost.csproj +++ b/backend/ControlService/src/ControlService.AppHost/ControlService.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe diff --git a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs index b90ab8b..3d5fd09 100644 --- a/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs +++ b/backend/ControlService/src/ControlService.Application/Auth/AuthErrors.cs @@ -3,7 +3,7 @@ namespace ControlService.Application.Auth; /// The authentication errors and their Portuguese messages, verbatim from -/// docs/product/features/authentication.md (ADR-0009). +/// docs/product/features/authentication.md. public static class AuthErrors { public static Error InvalidCredentials { get; } = new("invalid_credentials", "Login ou senha incorretos."); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs index fb6f400..1a0992f 100644 --- a/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs +++ b/backend/ControlService/src/ControlService.Application/Auth/ChangePassword/ChangePasswordHandler.cs @@ -41,7 +41,7 @@ public async Task> Handle(ChangePasswordCommand command, Ca await credentials.ReplacePasswordAsync(command.UserId, command.Password, cancellationToken); - // A new password ends every session, including the one that asked for the change (ADR-0019). + // A new password ends every session, including the one that asked for the change. await sessions.EndAllAsync(command.UserId, cancellationToken); var session = await sessions.StartAsync(command.UserId, cancellationToken); var accessToken = accessTokens.Issue(command.UserId, session.SessionId, mustChangePassword: false); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs index 4ba765e..3422de2 100644 --- a/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs +++ b/backend/ControlService/src/ControlService.Application/Auth/ICredentialStore.cs @@ -1,7 +1,7 @@ namespace ControlService.Application.Auth; /// Passwords, lockout and the mandatory-change flag. Credentials are not part of the user -/// record (AUTH-19); lockout counting lives behind this interface (ADR-0019). +/// record (AUTH-19); lockout counting lives behind this interface. public interface ICredentialStore { Task CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken); diff --git a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs index f583f6d..9311eec 100644 --- a/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs +++ b/backend/ControlService/src/ControlService.Application/Auth/ISessionStore.cs @@ -1,6 +1,6 @@ namespace ControlService.Application.Auth; -/// Sessions are refresh tokens stored hashed (AUTH-19, ADR-0019). +/// Sessions are refresh tokens stored hashed (AUTH-19). public interface ISessionStore { Task StartAsync(Guid userId, CancellationToken cancellationToken); @@ -10,6 +10,6 @@ public interface ISessionStore Task EndAllAsync(Guid userId, CancellationToken cancellationToken); /// Replaces a valid refresh token with a new one, or returns null when the token is - /// unknown or has expired. Two calls with the same token never both succeed (ADR-0019). + /// unknown or has expired. Two calls with the same token never both succeed. Task RotateAsync(string refreshToken, CancellationToken cancellationToken); } diff --git a/backend/ControlService/src/ControlService.Application/Common/ICommandHandler.cs b/backend/ControlService/src/ControlService.Application/Common/ICommandHandler.cs index dae7ead..9220b04 100644 --- a/backend/ControlService/src/ControlService.Application/Common/ICommandHandler.cs +++ b/backend/ControlService/src/ControlService.Application/Common/ICommandHandler.cs @@ -2,7 +2,7 @@ namespace ControlService.Application.Common; -/// Handles one command and returns its result (ADR-0007, ADR-0009). +/// Handles one command and returns its result. public interface ICommandHandler { Task> Handle(TCommand command, CancellationToken cancellationToken); diff --git a/backend/ControlService/src/ControlService.Application/Common/ICurrentUser.cs b/backend/ControlService/src/ControlService.Application/Common/ICurrentUser.cs index abbeb36..f7439db 100644 --- a/backend/ControlService/src/ControlService.Application/Common/ICurrentUser.cs +++ b/backend/ControlService/src/ControlService.Application/Common/ICurrentUser.cs @@ -1,6 +1,6 @@ namespace ControlService.Application.Common; -/// The signed-in person, backed by the authenticated principal (ADR-0015, ADR-0019). +/// The signed-in person, backed by the authenticated principal. /// Outside a request, the system acts as the Admin. public interface ICurrentUser { diff --git a/backend/ControlService/src/ControlService.Application/Common/IQueryHandler.cs b/backend/ControlService/src/ControlService.Application/Common/IQueryHandler.cs index de3122c..9dab8af 100644 --- a/backend/ControlService/src/ControlService.Application/Common/IQueryHandler.cs +++ b/backend/ControlService/src/ControlService.Application/Common/IQueryHandler.cs @@ -2,7 +2,7 @@ namespace ControlService.Application.Common; -/// Handles one query and returns its result (ADR-0007, ADR-0009). Queries may skip the +/// Handles one query and returns its result. Queries may skip the /// domain and project straight to response models. public interface IQueryHandler { diff --git a/backend/ControlService/src/ControlService.Application/Common/IUnitOfWork.cs b/backend/ControlService/src/ControlService.Application/Common/IUnitOfWork.cs index b55ad59..0ae8864 100644 --- a/backend/ControlService/src/ControlService.Application/Common/IUnitOfWork.cs +++ b/backend/ControlService/src/ControlService.Application/Common/IUnitOfWork.cs @@ -2,8 +2,8 @@ namespace ControlService.Application.Common; -/// Persists the changes tracked by the current unit of work (ADR-0009), translating a -/// concurrency conflict into the concurrency_conflict error (ADR-0014). +/// Persists the changes tracked by the current unit of work, translating a +/// concurrency conflict into the concurrency_conflict error. public interface IUnitOfWork { Task SaveChangesAsync(CancellationToken cancellationToken); diff --git a/backend/ControlService/src/ControlService.Application/Common/Unit.cs b/backend/ControlService/src/ControlService.Application/Common/Unit.cs index fa8f398..0064394 100644 --- a/backend/ControlService/src/ControlService.Application/Common/Unit.cs +++ b/backend/ControlService/src/ControlService.Application/Common/Unit.cs @@ -1,5 +1,5 @@ namespace ControlService.Application.Common; /// The response of a command that has nothing to return, since handlers always return a -/// Result<TResponse> (ADR-0009). +/// Result<TResponse>. public readonly record struct Unit; diff --git a/backend/ControlService/src/ControlService.Application/Common/ValidatingCommandHandler.cs b/backend/ControlService/src/ControlService.Application/Common/ValidatingCommandHandler.cs index 2b52017..9731094 100644 --- a/backend/ControlService/src/ControlService.Application/Common/ValidatingCommandHandler.cs +++ b/backend/ControlService/src/ControlService.Application/Common/ValidatingCommandHandler.cs @@ -3,7 +3,7 @@ namespace ControlService.Application.Common; -/// Validates a command before it reaches its handler (ADR-0008). +/// Validates a command before it reaches its handler. public sealed class ValidatingCommandHandler( ICommandHandler innerHandler, IValidator validator) : ICommandHandler diff --git a/backend/ControlService/src/ControlService.Application/ControlService.Application.csproj b/backend/ControlService/src/ControlService.Application/ControlService.Application.csproj index 64ea1de..2f72f4d 100644 --- a/backend/ControlService/src/ControlService.Application/ControlService.Application.csproj +++ b/backend/ControlService/src/ControlService.Application/ControlService.Application.csproj @@ -1,9 +1,9 @@ - + - + diff --git a/backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs b/backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs index 4fd5053..a720748 100644 --- a/backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs +++ b/backend/ControlService/src/ControlService.Domain/Access/ScreenKeys.cs @@ -1,6 +1,6 @@ namespace ControlService.Domain.Access; -/// Stable screen keys owned by the back-end (ADR-0021). Mirrors docs/product/screen-catalog.json. +/// Stable screen keys owned by the back-end. Mirrors docs/product/screen-catalog.json. public static class ScreenKeys { public const string Users = "gerenciamento/usuarios"; diff --git a/backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs b/backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs index fee9bea..582bf33 100644 --- a/backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs +++ b/backend/ControlService/src/ControlService.Domain/Common/AuditedAggregate.cs @@ -1,6 +1,6 @@ namespace ControlService.Domain.Common; -/// Audit and concurrency fields shared by every aggregate (ADR-0014, ADR-0015). +/// Audit and concurrency fields shared by every aggregate. /// Filled by infrastructure (the interceptor, the database), never by domain logic. public abstract class AuditedAggregate { diff --git a/backend/ControlService/src/ControlService.Domain/Common/Error.cs b/backend/ControlService/src/ControlService.Domain/Common/Error.cs index b12f2d4..9f7d553 100644 --- a/backend/ControlService/src/ControlService.Domain/Common/Error.cs +++ b/backend/ControlService/src/ControlService.Domain/Common/Error.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Common; [SuppressMessage( "Naming", "CA1716:Identifiers should not match keywords", - Justification = "The type name is Error by decision of ADR-0009 and the domain glossary.")] + Justification = "The type name is Error, as in the domain glossary.")] public sealed record Error( string Code, string Message, diff --git a/backend/ControlService/src/ControlService.Domain/Common/ResultOfT.cs b/backend/ControlService/src/ControlService.Domain/Common/ResultOfT.cs index f2cb081..a13fc3e 100644 --- a/backend/ControlService/src/ControlService.Domain/Common/ResultOfT.cs +++ b/backend/ControlService/src/ControlService.Domain/Common/ResultOfT.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Common; [SuppressMessage( "Design", "CA1000:Do not declare static members on generic types", - Justification = "Result.Success/Failure factories are the API shape decided in ADR-0009.")] + Justification = "Result.Success/Failure are the factories of the Result pattern used across the solution.")] public sealed class Result { private readonly T? _value; diff --git a/backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs b/backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs index fb21333..9dac97a 100644 --- a/backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs +++ b/backend/ControlService/src/ControlService.Domain/Common/SystemIds.cs @@ -1,6 +1,6 @@ namespace ControlService.Domain.Common; -/// Fixed, well-known ids of the system records that always exist (ADR-0022). +/// Fixed, well-known ids of the system records that always exist. public static class SystemIds { public static readonly Guid AdminUser = new("00000000-0000-7000-8000-000000000001"); diff --git a/backend/ControlService/src/ControlService.Domain/ControlService.Domain.csproj b/backend/ControlService/src/ControlService.Domain/ControlService.Domain.csproj index b5be03b..604dff0 100644 --- a/backend/ControlService/src/ControlService.Domain/ControlService.Domain.csproj +++ b/backend/ControlService/src/ControlService.Domain/ControlService.Domain.csproj @@ -1,5 +1,5 @@ - + diff --git a/backend/ControlService/src/ControlService.Domain/PermissionProfiles/ScreenLevel.cs b/backend/ControlService/src/ControlService.Domain/PermissionProfiles/ScreenLevel.cs index dc6797f..951e1e7 100644 --- a/backend/ControlService/src/ControlService.Domain/PermissionProfiles/ScreenLevel.cs +++ b/backend/ControlService/src/ControlService.Domain/PermissionProfiles/ScreenLevel.cs @@ -2,6 +2,6 @@ namespace ControlService.Domain.PermissionProfiles; -/// A profile's access level for one screen (ADR-0011). A screen missing from the +/// A profile's access level for one screen. A screen missing from the /// collection means Denied, so Denied is never stored explicitly. public sealed record ScreenLevel(ScreenKey Screen, AccessLevel Level); diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs index f3a2f8c..b284dfe 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/CredentialUserManager.cs @@ -7,7 +7,7 @@ namespace ControlService.Infrastructure.Auth; /// Identity's lockout reads `DateTimeOffset.UtcNow` directly, which tests cannot control. /// This manager overrides only the two virtual methods that compare or set the lockout end so they /// use the application's ; the counting and the threshold are Identity's -/// own logic, copied unchanged (ADR-0019). +/// own logic, copied unchanged. public sealed class CredentialUserManager( IUserStore store, IOptions optionsAccessor, diff --git a/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs b/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs index 4f9f431..3dcffad 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Auth/SessionStore.cs @@ -9,7 +9,7 @@ namespace ControlService.Infrastructure.Auth; /// Refresh tokens are 32 random bytes; only their SHA-256 is stored (AUTH-19). A session is -/// valid while now < expires_at, and every use slides that time forward (ADR-0032, T4). +/// valid while now < expires_at, and every use slides that time forward (T4). public sealed class SessionStore(AppDbContext db, IOptions options, TimeProvider timeProvider) : ISessionStore { private TimeSpan IdleTime => TimeSpan.FromHours(options.Value.RefreshTokenIdleHours); diff --git a/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj b/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj index a9d671d..00c7d82 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj +++ b/backend/ControlService/src/ControlService.Infrastructure/ControlService.Infrastructure.csproj @@ -1,6 +1,6 @@ - + @@ -9,7 +9,7 @@ - + diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs index 3148cf8..f640052 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AdminOptions.cs @@ -1,7 +1,7 @@ namespace ControlService.Infrastructure.Persistence; /// The Admin's e-mail and initial password come from configuration, each read only when -/// the record it creates is missing (USR-24, USR-33, AUTH-13, ADR-0022). +/// the record it creates is missing (USR-24, USR-33, AUTH-13). public sealed class AdminOptions { public const string SectionName = "Admin"; diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs index 13d5ad1..66d96a0 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AppDbContext.cs @@ -8,7 +8,7 @@ namespace ControlService.Infrastructure.Persistence; /// The domain aggregates plus the Identity tables of the credentials, with no roles: -/// permissions are per screen (ADR-0020). +/// permissions are per screen. public sealed class AppDbContext(DbContextOptions options) : IdentityUserContext(options) { // Identity's base context already has a `Users` set, of credentials. The domain users keep the diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AuditFieldsInterceptor.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AuditFieldsInterceptor.cs index 0dae46b..65ce6c1 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/AuditFieldsInterceptor.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/AuditFieldsInterceptor.cs @@ -6,7 +6,7 @@ namespace ControlService.Infrastructure.Persistence; -/// Fills the audit fields on every save (ADR-0015, CNV-10, CNV-19). A save during a request +/// Fills the audit fields on every save (CNV-10, CNV-19). A save during a request /// without a signed-in user throws, so nothing is written without an author (D3 of #7). public sealed class AuditFieldsInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) : SaveChangesInterceptor { diff --git a/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs b/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs index 352a66c..27d60d5 100644 --- a/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs +++ b/backend/ControlService/src/ControlService.Infrastructure/Persistence/SystemRecordsSeeder.cs @@ -8,7 +8,7 @@ namespace ControlService.Infrastructure.Persistence; /// Inserts the Admin, its credential and the Gerenciador profile only when missing, so -/// restarting the application never duplicates or changes them (ADR-0022, D2, D7: the Admin comes +/// restarting the application never duplicates or changes them (D2, D7: the Admin comes /// first, since the profile's authorship references it). The credential is checked apart from the /// Admin row, so databases created before authentication get it too (T3). public static class SystemRecordsSeeder diff --git a/backend/ControlService/src/ControlService.ServiceDefaults/ControlService.ServiceDefaults.csproj b/backend/ControlService/src/ControlService.ServiceDefaults/ControlService.ServiceDefaults.csproj index 2574aac..69c497e 100644 --- a/backend/ControlService/src/ControlService.ServiceDefaults/ControlService.ServiceDefaults.csproj +++ b/backend/ControlService/src/ControlService.ServiceDefaults/ControlService.ServiceDefaults.csproj @@ -1,6 +1,6 @@ - + true diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs index a40047c..c19d3a7 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminCredentialTests.cs @@ -11,7 +11,7 @@ namespace ControlService.Api.IntegrationTests.Auth; public sealed class AdminCredentialTests(ApiFactory factory) : IClassFixture { [Fact] - public async Task Startup_creates_the_admin_credential_with_a_mandatory_change() // AUTH-13, ADR-0022 + public async Task Startup_creates_the_admin_credential_with_a_mandatory_change() // AUTH-13 { // The first host already created it; removing it lets the next start prove it is created when missing. await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs index d0ee949..6b56bc8 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/AdminFirstAccessApiTests.cs @@ -35,7 +35,7 @@ public async Task Admin_with_the_initial_password_signs_in_with_must_change_pass } [Fact] - public async Task Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint() // AUTH-14, AUTH-27, ADR-0032 + public async Task Admin_with_the_initial_password_gets_401_password_change_required_on_another_endpoint() // AUTH-14, AUTH-27 { await AuthTestSupport.ResetAdminAsync(factory.Services); using var client = factory.CreateHttpsClient(); @@ -47,7 +47,7 @@ public async Task Admin_with_the_initial_password_gets_401_password_change_requi } [Fact] - public async Task Admin_with_the_initial_password_can_use_me_and_sign_out() // ADR-0032 + public async Task Admin_with_the_initial_password_can_use_me_and_sign_out() { await AuthTestSupport.ResetAdminAsync(factory.Services); using var client = factory.CreateHttpsClient(); @@ -91,7 +91,7 @@ public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints } [Fact] - public async Task Change_password_ends_the_other_sessions() // ADR-0019 + public async Task Change_password_ends_the_other_sessions() { await AuthTestSupport.ResetAdminAsync(factory.Services); using var client = factory.CreateHttpsClient(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs index ffb864b..09f66c2 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/MeApiTests.cs @@ -30,7 +30,7 @@ public async Task Me_returns_the_signed_in_person_and_their_levels() // Operatio } [Fact] - public async Task Me_without_a_token_returns_401_session_expired() // ADR-0009, AUTH-27, T7 + public async Task Me_without_a_token_returns_401_session_expired() // AUTH-27, T7 { using var client = factory.CreateHttpsClient(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs index 7a86089..12addfd 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitApiTests.cs @@ -7,7 +7,7 @@ public sealed class RateLimitApiTests(RateLimitedApiFactory factory) : IClassFix private const string TooManyRequests = "Muitas tentativas em pouco tempo. Aguarde alguns instantes e tente de novo."; [Fact] - public async Task Sign_in_over_the_rate_limit_returns_429_with_retry_after() // AUTH-26, ADR-0023 + public async Task Sign_in_over_the_rate_limit_returns_429_with_retry_after() // AUTH-26 { using var client = factory.CreateHttpsClient(); for (var request = 1; request <= 2; request++) @@ -24,7 +24,7 @@ public async Task Sign_in_over_the_rate_limit_returns_429_with_retry_after() // } [Fact] - public async Task Refresh_over_the_rate_limit_returns_429() // AUTH-26, ADR-0023 + public async Task Refresh_over_the_rate_limit_returns_429() // AUTH-26 { using var client = factory.CreateHttpsClient(); for (var request = 1; request <= 2; request++) diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs index a39ed9d..f4b140b 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RateLimitedApiFactory.cs @@ -5,7 +5,7 @@ namespace ControlService.Api.IntegrationTests.Auth; /// The real API with rate limits of two requests per window, so the third request is refused -/// without a test having to send dozens (T12, ADR-0023). +/// without a test having to send dozens (T12). public sealed class RateLimitedApiFactory(PostgresContainerFixture postgres) : AuthApiFactory(postgres) { protected override void ConfigureWebHost(IWebHostBuilder builder) diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs index 60871be..7b70bc3 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/RefreshApiTests.cs @@ -31,7 +31,7 @@ public async Task Refresh_rotates_the_cookie_and_the_old_one_stops_working() // } [Fact] - public async Task Refresh_slides_the_session_for_another_8_hours() // AUTH-17, ADR-0032 + public async Task Refresh_slides_the_session_for_another_8_hours() // AUTH-17 { var user = await AuthTestSupport.CreateUserAsync(factory.Services); using var client = factory.CreateHttpsClient(); @@ -48,7 +48,7 @@ public async Task Refresh_slides_the_session_for_another_8_hours() // AUTH-17, A } [Fact] - public async Task Refresh_after_8_idle_hours_returns_401_session_expired() // AUTH-17, ADR-0032 + public async Task Refresh_after_8_idle_hours_returns_401_session_expired() // AUTH-17 { var user = await AuthTestSupport.CreateUserAsync(factory.Services); using var client = factory.CreateHttpsClient(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs index 7d5654f..1dd470a 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignInApiTests.cs @@ -34,7 +34,7 @@ public async Task Sign_in_returns_200_with_an_access_token_and_the_refresh_cooki } [Fact] - public async Task Access_token_carries_the_user_id_in_sub_and_the_session_in_sid() // ADR-0019, D3 of #7 + public async Task Access_token_carries_the_user_id_in_sub_and_the_session_in_sid() // D3 of #7 { var user = await AuthTestSupport.CreateUserAsync(factory.Services); using var client = factory.CreateHttpsClient(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs index 0f5926f..65fc329 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SignOutApiTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Api.IntegrationTests.Auth; public sealed class SignOutApiTests(AuthApiFactory factory) : IClassFixture { [Fact] - public async Task Sign_out_returns_204_expires_the_cookie_and_ends_the_session() // ADR-0019, D3 + public async Task Sign_out_returns_204_expires_the_cookie_and_ends_the_session() // D3 { var user = await AuthTestSupport.CreateUserAsync(factory.Services); using var client = factory.CreateHttpsClient(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs index f31ce30..b15cefa 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Auth/SigningKeyTests.cs @@ -11,7 +11,7 @@ public sealed class SigningKeyTests(ApiFactory factory) : IClassFixture builder.ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(new Dictionary @@ -29,7 +29,7 @@ public void Api_does_not_start_without_a_valid_signing_key(string? invalidKey) / } [Fact] - public void Api_starts_with_a_signing_key_longer_than_64_bytes() // ADR-0019, T5: "at least 32 bytes" + public void Api_starts_with_a_signing_key_longer_than_64_bytes() // T5: "at least 32 bytes" { var longKey = Convert.ToBase64String(new byte[96].Select(_ => (byte)9).ToArray()); using var valid = factory.WithWebHostBuilder(builder => diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ErrorResultsTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ErrorResultsTests.cs index c239fb8..0192e7f 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ErrorResultsTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/ErrorResultsTests.cs @@ -7,12 +7,12 @@ namespace ControlService.Api.IntegrationTests.Common; -// Unit tests of the Error -> Problem Details mapping itself (ADR-0009). The full JSON shape, traceId +// Unit tests of the Error -> Problem Details mapping itself. The full JSON shape, traceId // and the generic 500 are checked through the real HTTP pipeline in ErrorResponseTests. public sealed class ErrorResultsTests { - // Every row of the ADR-0009 table. account_inactive defaults to 401 (during a session); - // the sign-in endpoint returns its 403 explicitly. + // Every row of the error code table in docs/api/conventions.md#error-codes. account_inactive + // defaults to 401 (during a session); the sign-in endpoint returns its 403 explicitly. [Theory] [InlineData("validation_failed", 400, "https://tools.ietf.org/html/rfc9110#section-15.5.1", "Bad Request")] [InlineData("session_expired", 401, "https://tools.ietf.org/html/rfc9110#section-15.5.2", "Unauthorized")] @@ -31,7 +31,7 @@ public sealed class ErrorResultsTests [InlineData("link_invalid", 410, "https://tools.ietf.org/html/rfc9110#section-15.5.11", "Gone")] [InlineData("locked_out", 429, "https://tools.ietf.org/html/rfc6585#section-4", "Too Many Requests")] [InlineData("unexpected_error", 500, "https://tools.ietf.org/html/rfc9110#section-15.6.1", "Internal Server Error")] - public async Task Every_documented_error_code_maps_to_its_status(string code, int status, string type, string title) // ADR-0009, API-14 + public async Task Every_documented_error_code_maps_to_its_status(string code, int status, string type, string title) // API-14 { var body = await ExecuteAsync(new Error(code, "Mensagem de teste.")); @@ -42,7 +42,7 @@ public async Task Every_documented_error_code_maps_to_its_status(string code, in } [Fact] - public async Task Details_of_an_error_are_returned_under_details() // ADR-0009 + public async Task Details_of_an_error_are_returned_under_details() { var error = new Error( "concurrency_conflict", @@ -55,7 +55,7 @@ public async Task Details_of_an_error_are_returned_under_details() // ADR-0009 } [Fact] - public void An_error_becomes_a_typed_problem_result() // ADR-0003: typed results feed the OpenAPI document + public void An_error_becomes_a_typed_problem_result() // Typed results feed the OpenAPI document { ProblemHttpResult result = new Error("not_found", "Registro não encontrado.").ToProblem(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/HttpCurrentUserTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/HttpCurrentUserTests.cs index c262bf4..171c601 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/HttpCurrentUserTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Common/HttpCurrentUserTests.cs @@ -8,7 +8,7 @@ namespace ControlService.Api.IntegrationTests.Common; public class HttpCurrentUserTests { [Fact] - public void Current_user_is_read_from_the_sub_claim() // ADR-0015, ADR-0019 + public void Current_user_is_read_from_the_sub_claim() { var userId = Guid.CreateVersion7(); var identity = new ClaimsIdentity([new Claim("sub", userId.ToString())], authenticationType: "Test"); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/ControlService.Api.IntegrationTests.csproj b/backend/ControlService/tests/ControlService.Api.IntegrationTests/ControlService.Api.IntegrationTests.csproj index 9d6edde..127caf5 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/ControlService.Api.IntegrationTests.csproj +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/ControlService.Api.IntegrationTests.csproj @@ -1,6 +1,6 @@ - + diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/AuditFieldsPersistenceTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/AuditFieldsPersistenceTests.cs index 063f0f5..8deacaf 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/AuditFieldsPersistenceTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/AuditFieldsPersistenceTests.cs @@ -11,7 +11,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class AuditFieldsPersistenceTests(PersistenceApiFactory factory) : IClassFixture { [Fact] - public async Task Creating_a_record_fills_authorship_from_the_current_user_and_the_clock() // CNV-10, CNV-19, ADR-0015 + public async Task Creating_a_record_fills_authorship_from_the_current_user_and_the_clock() // CNV-10, CNV-19 { var createdAt = new DateTimeOffset(2026, 9, 12, 13, 5, 44, TimeSpan.Zero); factory.Clock.Set(createdAt); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/ConcurrencyPersistenceTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/ConcurrencyPersistenceTests.cs index 1a5dd6c..b6f1ce1 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/ConcurrencyPersistenceTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/ConcurrencyPersistenceTests.cs @@ -12,7 +12,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class ConcurrencyPersistenceTests(PersistenceApiFactory factory) : IClassFixture { [Fact] - public async Task Saving_over_a_change_made_by_someone_else_returns_concurrency_conflict() // CNV-13, CNV-14, ADR-0014 + public async Task Saving_over_a_change_made_by_someone_else_returns_concurrency_conflict() // CNV-13, CNV-14 { var fabio = User.Create( Login.Create("fabio.nunes").Value, EmailAddress.Create("fabio.nunes@example.com").Value, "Fabio Nunes", "Fabio Nunes"); @@ -96,7 +96,7 @@ public async Task A_refused_save_writes_nothing() // CNV-13 } [Fact] - public async Task Saving_levels_over_a_change_made_by_someone_else_returns_concurrency_conflict() // CNV-13, CNV-14, ADR-0014 + public async Task Saving_levels_over_a_change_made_by_someone_else_returns_concurrency_conflict() // CNV-13, CNV-14 { var users = ScreenKey.Create(ScreenKeys.Users).Value; var profile = PermissionProfile.Create("Compras", "Equipe de compras"); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/PermissionProfilePersistenceTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/PermissionProfilePersistenceTests.cs index 879c658..a4c2d2b 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/PermissionProfilePersistenceTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/PermissionProfilePersistenceTests.cs @@ -9,7 +9,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class PermissionProfilePersistenceTests(PersistenceApiFactory factory) : IClassFixture { [Fact] - public async Task Profile_is_saved_and_read_back_with_its_levels() // ADR-0011, PERM-05 + public async Task Profile_is_saved_and_read_back_with_its_levels() // PERM-05 { var users = ScreenKey.Create(ScreenKeys.Users).Value; var customers = ScreenKey.Create(ScreenKeys.Customers).Value; diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/StartupSeedTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/StartupSeedTests.cs index f062a42..9fb12c3 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/StartupSeedTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/StartupSeedTests.cs @@ -12,7 +12,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class StartupSeedTests(ApiFactory factory) : IClassFixture { [Fact] - public async Task Startup_applies_the_schema_and_seeds_the_system_records() // ADR-0022, USR-23, USR-24, PERM-22, PERM-23, CNV-20 + public async Task Startup_applies_the_schema_and_seeds_the_system_records() // USR-23, USR-24, PERM-22, PERM-23, CNV-20 { await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -39,7 +39,7 @@ public async Task Startup_applies_the_schema_and_seeds_the_system_records() // A } [Fact] - public async Task Restarting_does_not_duplicate_or_change_the_system_records() // ADR-0022, USR-33 + public async Task Restarting_does_not_duplicate_or_change_the_system_records() // USR-33 { // Starts the original host first, with admin@example.com, before the "restart" below. _ = factory.Services; @@ -76,7 +76,7 @@ public void Api_does_not_start_without_a_valid_admin_email(string invalidEmail) } [Fact] - public void Api_uses_the_system_clock() // ADR-0015 + public void Api_uses_the_system_clock() { var timeProvider = factory.Services.GetRequiredService(); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UnitOfWorkPersistenceTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UnitOfWorkPersistenceTests.cs index 40e1c45..df56aec 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UnitOfWorkPersistenceTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UnitOfWorkPersistenceTests.cs @@ -10,7 +10,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class UnitOfWorkPersistenceTests(PersistenceApiFactory factory) : IClassFixture { [Fact] - public async Task Unit_of_work_saves_the_changes() // ADR-0009 + public async Task Unit_of_work_saves_the_changes() { var profile = PermissionProfile.Create("Perfil UoW", "Criado via IUnitOfWork"); diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UserPersistenceTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UserPersistenceTests.cs index 38400c1..1ca5991 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UserPersistenceTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/Persistence/UserPersistenceTests.cs @@ -9,7 +9,7 @@ namespace ControlService.Api.IntegrationTests.Persistence; public sealed class UserPersistenceTests(PersistenceApiFactory factory) : IClassFixture { [Fact] - public async Task User_is_saved_and_read_back_with_the_same_values() // CNV-04, ADR-0012 + public async Task User_is_saved_and_read_back_with_the_same_values() // CNV-04 { var login = Login.Create("ana.souza").Value; var email = EmailAddress.Create("ana.souza@example.com").Value; diff --git a/backend/ControlService/tests/ControlService.Api.IntegrationTests/RouteGroupTests.cs b/backend/ControlService/tests/ControlService.Api.IntegrationTests/RouteGroupTests.cs index 66a1542..5dac583 100644 --- a/backend/ControlService/tests/ControlService.Api.IntegrationTests/RouteGroupTests.cs +++ b/backend/ControlService/tests/ControlService.Api.IntegrationTests/RouteGroupTests.cs @@ -3,7 +3,7 @@ namespace ControlService.Api.IntegrationTests; -// The /api/v1 group itself is wiring (ADR-0003, API-01): no feature registers a route under it yet, +// The /api/v1 group itself is wiring (API-01): no feature registers a route under it yet, // so there is no meaningful Red here. This just proves the group mounts without the pipeline breaking. public sealed class RouteGroupTests(ApiFactory factory) : IClassFixture { diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs index 27b1ed1..8dec0dc 100644 --- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/ChangePasswordTests.cs @@ -98,7 +98,7 @@ public async Task Account_without_a_mandatory_change_cannot_use_change_password( } [Fact] - public async Task Changing_the_password_ends_every_session_and_starts_a_new_one() // ADR-0019, ADR-0032 + public async Task Changing_the_password_ends_every_session_and_starts_a_new_one() { var bed = new AuthTestBed(); var admin = bed.AddAdminWithInitialPassword("senha-inicial"); @@ -148,7 +148,7 @@ public async Task Change_password_of_a_user_that_no_longer_exists_is_refused_as_ } [Fact] - public async Task Change_password_stops_when_the_user_cannot_be_saved() // ADR-0014 + public async Task Change_password_stops_when_the_user_cannot_be_saved() { var bed = new AuthTestBed(); var admin = bed.AddAdminWithInitialPassword("senha-inicial"); diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs index 97359d4..556583a 100644 --- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignInTests.cs @@ -137,7 +137,7 @@ public async Task Deactivated_account_with_a_wrong_password_gets_invalid_credent } [Fact] - public async Task Account_with_a_mandatory_password_change_gets_a_restricted_token() // AUTH-14, ADR-0032 + public async Task Account_with_a_mandatory_password_change_gets_a_restricted_token() // AUTH-14 { var bed = new AuthTestBed(); var admin = bed.AddActiveUser("admin", "senha-inicial"); diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs index d2fa376..f248458 100644 --- a/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs +++ b/backend/ControlService/tests/ControlService.Application.Tests/Auth/SignOutTests.cs @@ -6,7 +6,7 @@ namespace ControlService.Application.Tests.Auth; public class SignOutTests { [Fact] - public async Task Sign_out_ends_only_the_current_session() // ADR-0019 + public async Task Sign_out_ends_only_the_current_session() { var bed = new AuthTestBed(); var user = bed.AddActiveUser("ana.souza", "senha-da-ana"); diff --git a/backend/ControlService/tests/ControlService.Application.Tests/Common/ValidatingCommandHandlerTests.cs b/backend/ControlService/tests/ControlService.Application.Tests/Common/ValidatingCommandHandlerTests.cs index 02eb008..5779f16 100644 --- a/backend/ControlService/tests/ControlService.Application.Tests/Common/ValidatingCommandHandlerTests.cs +++ b/backend/ControlService/tests/ControlService.Application.Tests/Common/ValidatingCommandHandlerTests.cs @@ -7,7 +7,7 @@ namespace ControlService.Application.Tests.Common; public class ValidatingCommandHandlerTests { [Fact] - public async Task Valid_command_reaches_the_handler() // ADR-0008 + public async Task Valid_command_reaches_the_handler() { var innerHandler = new FakeCommandHandler(); var sut = new ValidatingCommandHandler(innerHandler, new TestCommandValidator()); @@ -22,7 +22,7 @@ public async Task Valid_command_reaches_the_handler() // ADR-0008 } [Fact] - public async Task Invalid_command_returns_validation_failed_and_never_reaches_the_handler() // ADR-0008 + public async Task Invalid_command_returns_validation_failed_and_never_reaches_the_handler() { var innerHandler = new FakeCommandHandler(); var sut = new ValidatingCommandHandler(innerHandler, new TestCommandValidator()); @@ -37,7 +37,7 @@ public async Task Invalid_command_returns_validation_failed_and_never_reaches_th } [Fact] - public async Task Validation_errors_use_camelCase_field_paths() // ADR-0008 + public async Task Validation_errors_use_camelCase_field_paths() { var innerHandler = new FakeCommandHandler(); var sut = new ValidatingCommandHandler(innerHandler, new TestCommandValidator()); @@ -52,7 +52,7 @@ public async Task Validation_errors_use_camelCase_field_paths() // ADR-0008 } [Fact] - public async Task Command_with_no_validation_rules_passes_through() // ADR-0008 + public async Task Command_with_no_validation_rules_passes_through() { var innerHandler = new FakeCommandHandler(); var sut = new ValidatingCommandHandler(innerHandler, new EmptyValidator()); diff --git a/backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs b/backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs index 0e745f8..39df8c8 100644 --- a/backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs +++ b/backend/ControlService/tests/ControlService.ArchitectureTests/AggregateTests.cs @@ -6,7 +6,7 @@ namespace ControlService.ArchitectureTests; public sealed class AggregateTests { [Fact] - public void Domain_aggregates_have_no_public_setters() // issue #5 done-when, ADR-0006 + public void Domain_aggregates_have_no_public_setters() // issue #5 done-when { Type[] aggregates = [typeof(User), typeof(PermissionProfile)]; diff --git a/backend/ControlService/tests/ControlService.ArchitectureTests/ControlService.ArchitectureTests.csproj b/backend/ControlService/tests/ControlService.ArchitectureTests/ControlService.ArchitectureTests.csproj index aa80ac2..1b7e35f 100644 --- a/backend/ControlService/tests/ControlService.ArchitectureTests/ControlService.ArchitectureTests.csproj +++ b/backend/ControlService/tests/ControlService.ArchitectureTests/ControlService.ArchitectureTests.csproj @@ -1,6 +1,6 @@ - + diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeyTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeyTests.cs index 849366b..5f03f05 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeyTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeyTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Tests.Access; public class ScreenKeyTests { [Fact] - public void ScreenKey_accepts_a_key_from_the_catalog() // ADR-0021 + public void ScreenKey_accepts_a_key_from_the_catalog() { var result = ScreenKey.Create("gerenciamento/usuarios"); @@ -14,7 +14,7 @@ public void ScreenKey_accepts_a_key_from_the_catalog() // ADR-0021 } [Fact] - public void ScreenKey_rejects_a_key_not_in_the_catalog() // PERM-24, ADR-0021, ADR-0009 + public void ScreenKey_rejects_a_key_not_in_the_catalog() // PERM-24 { var result = ScreenKey.Create("gerenciamento/tela-que-nao-existe"); @@ -24,7 +24,7 @@ public void ScreenKey_rejects_a_key_not_in_the_catalog() // PERM-24, ADR-0021, A } [Fact] - public void Screen_keys_with_the_same_value_are_equal() // ADR-0021 + public void Screen_keys_with_the_same_value_are_equal() { var first = ScreenKey.Create("gerenciamento/usuarios").Value; var second = ScreenKey.Create("gerenciamento/usuarios").Value; diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeysTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeysTests.cs index 3e98f5a..96323f6 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeysTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Access/ScreenKeysTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Tests.Access; public class ScreenKeysTests { [Fact] - public void ScreenKeys_users_screen_matches_the_catalog() // ADR-0021, docs/product/screen-catalog.json + public void ScreenKeys_users_screen_matches_the_catalog() // docs/product/screen-catalog.json { ScreenKeys.Users.ShouldBe("gerenciamento/usuarios"); ScreenKeys.All.ShouldContain("gerenciamento/usuarios"); diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ErrorTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ErrorTests.cs index a3b26dc..91d0214 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ErrorTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ErrorTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Tests.Common; public class ErrorTests { [Fact] - public void Error_stores_code_and_message() // ADR-0009 + public void Error_stores_code_and_message() { var error = new Error("validation_failed", "Alguns campos precisam ser corrigidos."); @@ -14,7 +14,7 @@ public void Error_stores_code_and_message() // ADR-0009 } [Fact] - public void Error_can_be_created_with_field_errors() // ADR-0009 + public void Error_can_be_created_with_field_errors() { var fields = new Dictionary { @@ -27,7 +27,7 @@ public void Error_can_be_created_with_field_errors() // ADR-0009 } [Fact] - public void Error_can_be_created_with_details() // ADR-0009 + public void Error_can_be_created_with_details() { var details = new Dictionary { ["updatedByName"] = "Bruno Lima" }; diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultOfTTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultOfTTests.cs index 11a85dd..4fe9d7f 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultOfTTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultOfTTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Tests.Common; public class ResultOfTTests { [Fact] - public void Result_of_T_success_exposes_value() // ADR-0009 + public void Result_of_T_success_exposes_value() { var result = Result.Success(42); @@ -14,7 +14,7 @@ public void Result_of_T_success_exposes_value() // ADR-0009 } [Fact] - public void Result_of_T_failure_does_not_expose_a_value() // ADR-0009 + public void Result_of_T_failure_does_not_expose_a_value() { var error = new Error("not_found", "Registro não encontrado."); diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultTests.cs index 7435f8a..3fe14cf 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Common/ResultTests.cs @@ -5,7 +5,7 @@ namespace ControlService.Domain.Tests.Common; public class ResultTests { [Fact] - public void Result_success_has_no_error() // ADR-0009 + public void Result_success_has_no_error() { var result = Result.Success(); @@ -15,7 +15,7 @@ public void Result_success_has_no_error() // ADR-0009 } [Fact] - public void Result_failure_exposes_error() // ADR-0009 + public void Result_failure_exposes_error() { var error = new Error("validation_failed", "Alguns campos precisam ser corrigidos."); diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/PermissionProfiles/PermissionProfileTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/PermissionProfiles/PermissionProfileTests.cs index eec1991..6daacd1 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/PermissionProfiles/PermissionProfileTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/PermissionProfiles/PermissionProfileTests.cs @@ -47,7 +47,7 @@ public void Setting_a_level_again_replaces_the_previous_one() // PERM-01 } [Fact] - public void Setting_a_level_to_denied_removes_the_screen_from_the_stored_levels() // PERM-05, ADR-0011 + public void Setting_a_level_to_denied_removes_the_screen_from_the_stored_levels() // PERM-05 { var profile = TestData.NewProfile(); var users = ScreenKey.Create(ScreenKeys.Users).Value; @@ -60,7 +60,7 @@ public void Setting_a_level_to_denied_removes_the_screen_from_the_stored_levels( } [Fact] - public void SetLevel_changes_only_that_screen() // ADR-0006 + public void SetLevel_changes_only_that_screen() { var profile = TestData.NewProfile(); var users = ScreenKey.Create(ScreenKeys.Users).Value; diff --git a/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs b/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs index 9602430..723097d 100644 --- a/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs +++ b/backend/ControlService/tests/ControlService.Domain.Tests/Users/UserTests.cs @@ -52,7 +52,7 @@ public void New_user_is_pending() // AUTH-02 } [Fact] - public void New_user_gets_a_version_7_id() // CNV-04, ADR-0012 + public void New_user_gets_a_version_7_id() // CNV-04 { var user = TestData.NewUser(); @@ -220,7 +220,7 @@ public void User_may_have_no_profiles() // USR-21, USR-13 } [Fact] - public void Assigning_profiles_replaces_them_and_ignores_repeated_ids() // ADR-0006 + public void Assigning_profiles_replaces_them_and_ignores_repeated_ids() { var user = TestData.NewUser(); var first = Guid.CreateVersion7(); diff --git a/backend/ControlService/tests/Directory.Build.props b/backend/ControlService/tests/Directory.Build.props index 7c26869..de8916f 100644 --- a/backend/ControlService/tests/Directory.Build.props +++ b/backend/ControlService/tests/Directory.Build.props @@ -1,5 +1,5 @@ - +