diff --git a/backend/src/middleware/requireRole.test.ts b/backend/src/middleware/requireRole.test.ts new file mode 100644 index 0000000..1ccbc5d --- /dev/null +++ b/backend/src/middleware/requireRole.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { once } from "node:events"; +import { requireRole } from "./requireRole.js"; +import { UserRole } from "../modules/auth/auth.types.js"; + +test("escrita de projetos exige sessão e perfil PO ou administrador", async () => { + let role: UserRole | undefined; + const app = express(); + app.use((req, _res, next) => { + if (role) req.auth = { id: "user", nome: "Pessoa", email: "test@example.com", role }; + next(); + }); + app.post("/projects", requireRole("admin", "po"), (_req, res) => { res.sendStatus(201); }); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address(); + assert.ok(address && typeof address !== "string"); + for (const [profile, expected] of [[undefined, 401], ["dev", 403], ["po", 201], ["admin", 201]] as const) { + role = profile; + const result = await fetch(`http://127.0.0.1:${address.port}/projects`, { method: "POST" }); + assert.equal(result.status, expected, `perfil ${profile}`); + } + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } +}); diff --git a/backend/src/middleware/requireRole.ts b/backend/src/middleware/requireRole.ts new file mode 100644 index 0000000..c1efe83 --- /dev/null +++ b/backend/src/middleware/requireRole.ts @@ -0,0 +1,16 @@ +import { NextFunction, Request, Response } from "express"; +import { UserRole } from "../modules/auth/auth.types.js"; + +export function requireRole(...roles: UserRole[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.auth) { + res.status(401).json({ error: "Autenticação necessária.", code: "UNAUTHORIZED" }); + return; + } + if (!roles.includes(req.auth.role)) { + res.status(403).json({ error: "Seu perfil não permite alterar projetos.", code: "FORBIDDEN" }); + return; + } + next(); + }; +} diff --git a/backend/src/modules/projects/projects.routes.ts b/backend/src/modules/projects/projects.routes.ts index d65e015..adc61c0 100644 --- a/backend/src/modules/projects/projects.routes.ts +++ b/backend/src/modules/projects/projects.routes.ts @@ -1,11 +1,13 @@ import { Router } from "express"; import { projectsController } from "./projects.controller.js"; +import { requireRole } from "../../middleware/requireRole.js"; export const projectsRouter = Router(); -projectsRouter.post("/", projectsController.create); +const canWrite = requireRole("admin", "po"); +projectsRouter.post("/", canWrite, projectsController.create); projectsRouter.get("/", projectsController.list); projectsRouter.get("/:id", projectsController.getById); -projectsRouter.put("/:id", projectsController.update); -projectsRouter.patch("/:id", projectsController.update); -projectsRouter.patch("/:id/archive", projectsController.archive); +projectsRouter.put("/:id", canWrite, projectsController.update); +projectsRouter.patch("/:id", canWrite, projectsController.update); +projectsRouter.patch("/:id/archive", canWrite, projectsController.archive); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5dcba3a..1cca77e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -98,6 +98,7 @@ paths: post: summary: Cadastra um novo projeto (PBI-01.1.1 / S1-03) operationId: createProject + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. security: - cookieAuth: [] requestBody: @@ -113,6 +114,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ProjectResponse' + '403': + description: Perfil sem permissão de escrita '400': $ref: '#/components/responses/ValidationError' '401': @@ -207,6 +210,7 @@ paths: put: summary: Atualiza dados de um projeto (S1-03 / S1-08) operationId: updateProject + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. PATCH no mesmo caminho é um alias desta operação. security: - cookieAuth: [] parameters: @@ -229,6 +233,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ProjectResponse' + '403': + description: Perfil sem permissão de escrita '400': $ref: '#/components/responses/ValidationError' '401': @@ -250,6 +256,7 @@ paths: patch: summary: Arquiva um projeto (S1-03 / S1-09) operationId: archiveProject + description: Escrita permitida apenas aos perfis po e admin; dev recebe 403. security: - cookieAuth: [] parameters: @@ -275,6 +282,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ProjectResponse' + '403': + description: Perfil sem permissão de escrita '401': $ref: '#/components/responses/Unauthorized' '404': @@ -609,4 +618,4 @@ components: content: application/json: schema: - $ref: '#/components/schemas/Error' \ No newline at end of file + $ref: '#/components/schemas/Error' diff --git a/frontend/AUTH.md b/frontend/AUTH.md new file mode 100644 index 0000000..6b0f942 --- /dev/null +++ b/frontend/AUTH.md @@ -0,0 +1,32 @@ +# S1-02 — Autenticação no frontend + +Login, logout, restauração de sessão e proteção das páginas internas integrados +à S1-01. A sessão usa cookie HttpOnly, sem credenciais no armazenamento do navegador. + +## Contrato implementado + +- `POST /api/v1/auth/login`: `{ email, password }`; retorna `{ user: { id, nome, email, role } }`. +- `GET /api/v1/auth/me`: restaura a sessão com o mesmo formato de usuário. +- `POST /api/v1/auth/logout`: revoga a sessão; retorna 204. + +O adaptador do frontend converte `nome` para o campo visual `name` e valida o papel. +401 exige login; 403 informa acesso indisponível; 429 informa limite de tentativas. +Erros de rede e respostas inválidas bloqueiam o conteúdo com opção de tentar novamente. +A saída só é confirmada após sucesso ou 401 do servidor. + +Rotas protegidas: `/`, `/requirements` e `/rag`. Destinos de retorno são validados +em `src/auth/navigation.ts`, preservando filtros e fragmentos de páginas conhecidas. +A sessão é revalidada ao navegar, usar o histórico e recuperar foco, sem polling. +O cliente HTTP compartilhado informa expiração ao receber 401 em recursos privados. + +## Validação + +No frontend: `npm test` e `npm run build`. +No backend: `npm ci`, `npm run build` e `npm test`. +Os testes do frontend usam o contrato da S1-01; os testes HTTP do backend cobrem +cookies, autenticação e consulta da sessão, com repositórios em memória. + +Para execução com PostgreSQL, configure os arquivos de ambiente conforme o README, +aplique as migrações (inclusive `004_identity_domain.sql`) e utilize um usuário +ativo com hash de senha compatível com a S1-01. O proxy Vite encaminha `/api` à +porta 3001; o frontend inicia na porta 5173. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 748b0a9..5bf4f3c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,13 +13,37 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "typescript": "^5.7.3", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^3.2.7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -254,6 +278,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -302,6 +336,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -802,9 +951,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -913,9 +1059,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -930,9 +1073,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -947,9 +1087,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -964,9 +1101,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -981,9 +1115,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -998,9 +1129,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1015,9 +1143,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1032,9 +1157,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1049,9 +1171,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1066,9 +1185,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1083,9 +1199,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1100,9 +1213,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1117,9 +1227,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1210,6 +1317,63 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1255,6 +1419,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1303,6 +1485,177 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -1350,6 +1703,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -1371,6 +1734,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1378,6 +1768,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1385,7 +1789,21 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", @@ -1403,6 +1821,42 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.422", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", @@ -1410,6 +1864,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1462,6 +1936,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1505,6 +1999,67 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1512,6 +2067,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1538,6 +2133,13 @@ "node": ">=6" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1557,6 +2159,27 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1593,6 +2216,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1642,6 +2302,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1663,6 +2349,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1719,6 +2413,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1735,6 +2456,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1745,6 +2473,61 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1762,6 +2545,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1882,6 +2741,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a8c1b95..254da1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { + "test": "vitest run", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" @@ -14,10 +15,13 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "typescript": "^5.7.3", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^3.2.7" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d52d2e..fd628d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,6 @@ import React, { useState, useEffect } from "react"; +import { navigate, routes } from "./auth/navigation"; +import { useAuth } from "./auth/Auth"; import { Cpu, Layers, @@ -17,7 +19,15 @@ interface ServiceStatus { } export const App: React.FC = () => { - const [activeTab, setActiveTab] = useState<"architecture" | "requirements" | "rag">("architecture"); + const { session, logout } = useAuth(); + const [pathname, setPathname] = useState(window.location.pathname); + const activeTab = (Object.keys(routes) as Array).find(key => routes[key] === pathname); + const setActiveTab = (tab: keyof typeof routes) => navigate(routes[tab]); + useEffect(() => { + const update = () => setPathname(window.location.pathname); + window.addEventListener("popstate", update); + return () => window.removeEventListener("popstate", update); + }, []); const [backendHealth, setBackendHealth] = useState("checking"); const [ollamaHealth, setOllamaHealth] = useState("checking"); @@ -162,6 +172,8 @@ export const App: React.FC = () => {
+ {session.user?.name} + Ambiente Local Ativo @@ -171,6 +183,7 @@ export const App: React.FC = () => { {/* Main Content Area */}
+ {!activeTab &&

Página não encontrada

} {activeTab === "architecture" && (
diff --git a/frontend/src/auth/Auth.test.tsx b/frontend/src/auth/Auth.test.tsx new file mode 100644 index 0000000..8faa7f3 --- /dev/null +++ b/frontend/src/auth/Auth.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AuthGate, AuthProvider, useAuth } from "./Auth"; +import { navigate, safeDestination } from "./navigation"; +import { apiRequest, readUser } from "./api"; +import { useLayoutEffect, useRef } from "react"; + +const user = { id: "1", nome: "Pessoa", role: "po", email: "pessoa@example.com" }; +const reply = (status: number, body: unknown = {}) => Promise.resolve(new Response(JSON.stringify(body), { status })); +function Content() { + const { logout } = useAuth(); + return
Conteúdo interno + + +
; +} +function mount() { render(); } +beforeEach(() => { window.history.replaceState(null, "", "/"); }); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it("adapta o usuário da S1-01 e rejeita um contrato de sessão inválido", async () => { + await expect(readUser(new Response(JSON.stringify({ user })))).resolves.toEqual({ + id: user.id, name: user.nome, email: user.email, role: user.role, + }); + await expect(readUser(new Response(JSON.stringify({ user: { ...user, nome: undefined, name: "Pessoa" } })))).rejects.toThrow("Resposta de sessão inválida"); +}); + +describe("retorno seguro", () => { + it.each(["https://evil.example", "//evil.example", "/\\evil.example", "/login", "/logout", "/%2f%2fevil.example", "/unknown", "javascript:alert(1)"])("recusa %s", value => { + expect(safeDestination(value)).toBe("/"); + }); + it("preserva filtros e fragmento de uma rota interna", () => { + expect(safeDestination("/requirements?filter=active#list")).toBe("/requirements?filter=active#list"); + }); +}); + +it("não mostra conteúdo enquanto restaura e aceita sessão validada pelo servidor", async () => { + let resolve!: (response: Response) => void; + vi.stubGlobal("fetch", vi.fn(() => new Promise(done => { resolve = done; }))); + mount(); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + resolve(new Response(JSON.stringify({ user }))); + expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); +}); + +it("protege acesso direto e retorna ao destino após login", async () => { + window.history.replaceState(null, "", "/requirements?filter=active#list"); + const fetchMock = vi.fn().mockImplementationOnce(() => reply(401)).mockImplementationOnce(() => reply(200, { user })); + vi.stubGlobal("fetch", fetchMock); + mount(); + await screen.findByText("Entre na sua conta"); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + fireEvent.change(screen.getByLabelText("E-mail"), { target: { value: user.email } }); + fireEvent.change(screen.getByLabelText("Senha"), { target: { value: "secret" } }); + fireEvent.click(screen.getByText("Entrar")); + await screen.findByText("Conteúdo interno"); + expect(window.location.pathname + window.location.search + window.location.hash).toBe("/requirements?filter=active#list"); + expect(fetchMock.mock.calls[1][1].credentials).toBe("same-origin"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/auth/me"); + expect(fetchMock.mock.calls[1][0]).toBe("/api/v1/auth/login"); + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ email: user.email, password: "secret" }); +}); + +it("não revela o campo incorreto e limpa a senha após falha", async () => { + vi.stubGlobal("fetch", vi.fn(() => reply(401))); + mount(); + await screen.findByText("Entrar"); + fireEvent.change(screen.getByLabelText("E-mail"), { target: { value: user.email } }); + fireEvent.change(screen.getByLabelText("Senha"), { target: { value: "wrong" } }); + fireEvent.click(screen.getByText("Entrar")); + expect(await screen.findByRole("alert")).toHaveProperty("textContent", "E-mail ou senha inválidos."); + expect((screen.getByLabelText("Senha") as HTMLInputElement).value).toBe(""); +}); + +it("bloqueia o conteúdo se a restauração falhar e permite nova tentativa", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("offline")).mockImplementationOnce(() => reply(200, { user }))); + mount(); + fireEvent.click(await screen.findByText("Tentar novamente")); + expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); +}); + +it("encerra a sessão e impede reabertura do conteúdo pelo histórico", async () => { + vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 204 })))); + mount(); + fireEvent.click(await screen.findByText("Sair")); + await screen.findByText("Entrar"); + window.history.pushState(null, "", "/rag"); + fireEvent.popState(window); + await waitFor(() => expect(window.location.pathname).toBe("/login")); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); +}); + +it("não confirma logout quando o servidor falha", async () => { + vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(503))); + mount(); + fireEvent.click(await screen.findByText("Sair")); + expect(await screen.findByRole("alert")).toHaveProperty("textContent", "Não foi possível confirmar a saída. Tente encerrar a sessão novamente."); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); +}); + +it("exige nova autenticação quando uma chamada informa expiração", async () => { + vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401))); + mount(); + await screen.findByText("Conteúdo interno"); + fireEvent.click(screen.getByText("Carregar projetos")); + expect(await screen.findByText("Sua sessão expirou. Entre novamente para continuar.")).toBeTruthy(); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); +}); + +it.each([ + [403, "Acesso indisponível. Entre em contato com o administrador."], + [429, "Muitas tentativas. Aguarde antes de tentar novamente."], +] as const)("apresenta orientação para resposta %s no login", async (status, message) => { + vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(401)).mockImplementationOnce(() => reply(status))); + mount(); + await screen.findByText("Entrar"); + fireEvent.change(screen.getByLabelText("E-mail"), { target: { value: user.email } }); + fireEvent.change(screen.getByLabelText("Senha"), { target: { value: "secret" } }); + fireEvent.click(screen.getByText("Entrar")); + expect(await screen.findByRole("alert")).toHaveProperty("textContent", message); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); +}); + +it.each(["focus", "navigation", "history"])("informa expiração detectada por %s e preserva o destino", async trigger => { + const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); + vi.stubGlobal("fetch", fetchMock); + mount(); + await screen.findByText("Conteúdo interno"); + if (trigger === "focus") fireEvent.focus(window); + else if (trigger === "navigation") fireEvent.click(screen.getByText("Requisitos")); + else { + window.history.replaceState(null, "", "/requirements?filter=active#list"); + fireEvent.popState(window); + } + expect(await screen.findByText("Sua sessão expirou. Entre novamente para continuar.")).toBeTruthy(); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + expect(new URLSearchParams(window.location.search).get("returnTo")).toBe(trigger === "focus" ? "/" : "/requirements?filter=active#list"); + expect(fetchMock.mock.calls[1][0]).toBe("/api/v1/auth/me"); +}); + +it("oculta conteúdo até a revalidação da navegação terminar", async () => { + let resolve!: (response: Response) => void; + vi.stubGlobal("fetch", vi.fn().mockImplementationOnce(() => reply(200, { user })) + .mockImplementationOnce(() => new Promise(done => { resolve = done; }))); + mount(); + fireEvent.click(await screen.findByText("Requisitos")); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); + expect(screen.getByRole("status").textContent).toBe("Verificando sessão…"); + resolve(new Response(JSON.stringify({ user }))); + expect(await screen.findByText("Conteúdo interno")).toBeTruthy(); +}); + +it("não mostra aviso de expiração no primeiro acesso anônimo", async () => { + vi.stubGlobal("fetch", vi.fn(() => reply(401))); + mount(); + await screen.findByText("Entrar"); + expect(screen.queryByText("Sua sessão expirou. Entre novamente para continuar.")).toBeNull(); +}); + +it("revalida histórico imediatamente após restaurar a sessão, antes dos efeitos passivos", async () => { + const fetchMock = vi.fn().mockImplementationOnce(() => reply(200, { user })).mockImplementationOnce(() => reply(401)); + vi.stubGlobal("fetch", fetchMock); + function ImmediateNavigation() { + const sent = useRef(false); + useLayoutEffect(() => { + if (sent.current) return; + sent.current = true; + window.history.replaceState(null, "", "/requirements?filter=active#list"); + window.dispatchEvent(new PopStateEvent("popstate")); + }, []); + return ; + } + render(); + expect(await screen.findByText("Sua sessão expirou. Entre novamente para continuar.")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(screen.queryByText("Conteúdo interno")).toBeNull(); +}); diff --git a/frontend/src/auth/Auth.tsx b/frontend/src/auth/Auth.tsx new file mode 100644 index 0000000..f83515a --- /dev/null +++ b/frontend/src/auth/Auth.tsx @@ -0,0 +1,157 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { ApiError, apiRequest, readUser, type User } from "./api"; +import { loginDestination, navigate, safeDestination } from "./navigation"; + +type Session = { status: "loading" | "anonymous" | "error"; user?: never } | { status: "authenticated"; user: User }; +const AuthContext = createContext<{ + session: Session; notice: string; restore: () => Promise; + login: (email: string, password: string) => Promise; logout: () => Promise; +} | null>(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSessionState] = useState({ status: "loading" }); + const sessionStatus = useRef("loading"); + // Native events can arrive before React commits passive effects. + const setSession = useCallback((next: Session) => { + sessionStatus.current = next.status; + setSessionState(next); + }, []); + const [notice, setNotice] = useState(""); + const revision = useRef(0); + const hadSession = useRef(false); + const expire = useCallback(() => { + revision.current++; + hadSession.current = false; + setNotice("Sua sessão expirou. Entre novamente para continuar."); + setSession({ status: "anonymous" }); + }, [setSession]); + const restore = useCallback(async () => { + const current = ++revision.current; + setSession({ status: "loading" }); + try { + const user = await readUser(await apiRequest("/auth/me")); + if (current === revision.current) { + hadSession.current = true; + setNotice(""); + setSession({ status: "authenticated", user }); + } + } catch (error) { + if (current !== revision.current) return; + if (error instanceof ApiError && error.status === 401) { + if (hadSession.current) expire(); + else setSession({ status: "anonymous" }); + } else setSession({ status: "error" }); + } + }, [expire, setSession]); + useEffect(() => { void restore(); return () => { revision.current++; }; }, [restore]); + useEffect(() => { + window.addEventListener("session-expired", expire); + return () => window.removeEventListener("session-expired", expire); + }, [expire]); + // Validate on navigation (including Back/Forward) and return to the app. + // No polling: idle expiration remains authoritative on the server. + useEffect(() => { + const revalidate = () => { if (sessionStatus.current === "authenticated") void restore(); }; + window.addEventListener("focus", revalidate); + window.addEventListener("popstate", revalidate); + return () => { + window.removeEventListener("focus", revalidate); + window.removeEventListener("popstate", revalidate); + }; + }, [restore]); + async function login(email: string, password: string) { + const current = ++revision.current; + const user = await readUser(await apiRequest("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) })); + if (current !== revision.current) return; + hadSession.current = true; + setNotice(""); + navigate(loginDestination(), true); + setSession({ status: "authenticated", user }); + } + async function logout() { + const current = ++revision.current; + setSession({ status: "loading" }); + try { + await apiRequest("/auth/logout", { method: "POST" }); + } catch (error) { + if (current !== revision.current) return; + if (!(error instanceof ApiError && error.status === 401)) { + setNotice("Não foi possível confirmar a saída. Tente encerrar a sessão novamente."); + setSession({ status: "error" }); + return; + } + } + if (current !== revision.current) return; + hadSession.current = false; + setNotice("Sessão encerrada."); + setSession({ status: "anonymous" }); + navigate("/login", true); + } + return {children}; +} + +export function useAuth() { + const auth = useContext(AuthContext); + if (!auth) throw new Error("AuthProvider ausente"); + return auth; +} + +export function AuthGate({ children }: { children: ReactNode }) { + const { session, notice, restore, logout } = useAuth(); + const [location, setLocation] = useState(() => window.location.href); + useEffect(() => { + const update = () => setLocation(window.location.href); + window.addEventListener("popstate", update); + return () => window.removeEventListener("popstate", update); + }, []); + const url = new URL(location); + const isLogin = url.pathname === "/login"; + useEffect(() => { + if (session.status === "anonymous" && !isLogin) { + navigate(`/login?returnTo=${encodeURIComponent(safeDestination(url.pathname + url.search + url.hash))}`, true); + } else if (session.status === "authenticated" && isLogin) navigate(loginDestination(), true); + }, [session.status, isLogin, location]); + if (session.status === "loading") return
Verificando sessão…
; + if (session.status === "error") return
+

Não foi possível verificar a sessão

+

{notice || "Verifique sua conexão e tente novamente."}

+ + +
; + if (session.status === "anonymous") return isLogin ? : null; + return isLogin ? null : children; +} + +function Login() { + const { login, notice } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return
+ Sinapse +

Entre na sua conta

+

Acesse a memória institucional da sua equipe.

+ {notice &&

{notice}

} +
{ + event.preventDefault(); + if (busy) return; + setBusy(true); setError(""); + try { await login(email.trim(), password); } + catch (failure) { + setPassword(""); + setError(failure instanceof ApiError && failure.status === 401 ? "E-mail ou senha inválidos." + : failure instanceof ApiError && failure.status === 403 ? "Acesso indisponível. Entre em contato com o administrador." + : failure instanceof ApiError && failure.status === 429 ? "Muitas tentativas. Aguarde antes de tentar novamente." + : "Não foi possível entrar. Verifique sua conexão e tente novamente."); + } finally { setBusy(false); } + }}> + + setEmail(e.target.value)} disabled={busy} /> + + setPassword(e.target.value)} disabled={busy} /> + {error &&

{error}

} + +
+
; +} diff --git a/frontend/src/auth/api.ts b/frontend/src/auth/api.ts new file mode 100644 index 0000000..8c4a018 --- /dev/null +++ b/frontend/src/auth/api.ts @@ -0,0 +1,32 @@ +export interface User { id: string; name: string; email: string; role: "admin" | "po" | "dev" } + +export class ApiError extends Error { + constructor(public status: number) { super(`HTTP ${status}`); } +} + +// Session credentials stay in a server-managed HttpOnly cookie. +export async function apiRequest(path: string, init: RequestInit = {}) { + const response = await fetch(`/api/v1${path}`, { + ...init, + credentials: "same-origin", + cache: "no-store", + signal: init.signal ?? AbortSignal.timeout(15000), + headers: { ...init.headers, "Content-Type": "application/json" }, + }); + if (!response.ok) { + if (response.status === 401 && !path.startsWith("/auth/")) { + window.dispatchEvent(new Event("session-expired")); + } + throw new ApiError(response.status); + } + return response; +} + +export async function readUser(response: Response): Promise { + const data = await response.json(); + if (!data?.user || typeof data.user.id !== "string" || typeof data.user.nome !== "string" || typeof data.user.email !== "string" + || !["admin", "po", "dev"].includes(data.user.role)) { + throw new Error("Resposta de sessão inválida"); + } + return { id: data.user.id, name: data.user.nome, email: data.user.email, role: data.user.role }; +} diff --git a/frontend/src/auth/navigation.ts b/frontend/src/auth/navigation.ts new file mode 100644 index 0000000..5282cdd --- /dev/null +++ b/frontend/src/auth/navigation.ts @@ -0,0 +1,20 @@ +export const routes = { architecture: "/", requirements: "/requirements", rag: "/rag" } as const; + +/** Only known internal pages are valid post-login destinations. */ +export function safeDestination(value: string | null): string { + if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\s\u0000-\u001f]/.test(value)) return "/"; + try { + const url = new URL(value, "https://sinapse.invalid"); + if (url.origin !== "https://sinapse.invalid" || !Object.values(routes).some(path => path === url.pathname)) return "/"; + return url.pathname + url.search + url.hash; + } catch { return "/"; } +} + +export function navigate(path: string, replace = false) { + window.history[replace ? "replaceState" : "pushState"](null, "", path); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function loginDestination() { + return safeDestination(new URLSearchParams(window.location.search).get("returnTo")); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 834c638..1e05fa4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -35,6 +35,17 @@ --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); } +.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.auth-card { width: 100%; max-width: 440px; padding: 32px; display: grid; gap: 18px; } +.auth-card h1 { font-size: 1.6rem; } +.auth-card p { color: var(--text-secondary); } +.auth-card form { display: grid; gap: 12px; } +.auth-card input { width: 100%; padding: 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); background: var(--bg-secondary); color: var(--text-primary); font: inherit; } +.auth-card [role="alert"] { color: #fca5a5; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--accent-secondary); outline-offset: 3px; } +button:disabled { opacity: .6; cursor: wait; } +@media (max-width: 900px) { header { flex-wrap: wrap; gap: 16px; } } + * { margin: 0; padding: 0; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0e5232b..5dff244 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.js"; import "./index.css"; +import { AuthGate, AuthProvider } from "./auth/Auth"; ReactDOM.createRoot(document.getElementById("root")!).render( - + );