diff --git a/.env.example b/.env.example index 0afa799..19da9c4 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,14 @@ API_HOST=127.0.0.1 API_PORT=3000 DATABASE_URL="postgresql://taskdesk:taskdesk_local@localhost:5432/taskdesk?schema=public" + +# Explicit local-only bypass; rejected by production and Vercel runtimes. +NODE_ENV=development +AUTH_MODE=local +VITE_AUTH_MODE=local +# Hosted auth: set AUTH_MODE=supabase and omit VITE_AUTH_MODE. +# Publishable keys are public. Never put secret/service_role keys in VITE_*. +SUPABASE_URL= +SUPABASE_PUBLISHABLE_KEY= +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c95dee..defbe06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,10 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - run: npm ci --no-audit --no-fund + - name: Audit high and critical runtime advisories + run: npm run audit:prod - name: Generate Prisma Client run: npm run db:generate - name: Apply database migrations diff --git a/README.md b/README.md index f9e1f52..768265b 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ Plataforma pessoal de organização de tarefas a partir de mensagens acionáveis, sem transformar todo e-mail em chamado. -**Status atual:** API CRUD e dashboard Vue conectados. O painel lista, cria e atualiza tarefas pela API. Gmail e Microsoft 365 ainda não estão conectados. +**Status atual:** CRUD, histórico e isolamento por usuário implementados, com login por Supabase Auth. A ativação hospedada depende de um projeto Supabase dedicado e das variáveis de ambiente. Gmail e Microsoft 365 ainda não estão conectados. ## Stack - Web: Vue 3, Vite, TypeScript e CSS puro. - API: Node.js, Express, TypeScript e Zod. -- Banco: PostgreSQL 16 e Prisma 6. +- Banco: PostgreSQL 16 e Prisma 7. - Testes: Vitest e Supertest. ## Requisitos -Node.js 20.15+, npm 10+ e Docker Engine com Docker Compose para PostgreSQL local. +Node.js 22.12+, npm 10+ e Docker Engine com Docker Compose para PostgreSQL local. ## Executar localmente ```bash @@ -46,7 +46,7 @@ Status: `NEW`, `IN_PROGRESS`, `WAITING`, `COMPLETED`. Prioridades: `LOW`, `NORMAL`, `HIGH`, `URGENT`. ## Segurança e escopo -A autenticação e autorização por usuário ainda não foram implementadas. Não exponha a API publicamente nem utilize dados pessoais reais até que esses controles estejam prontos. Integrações de e-mail permanecem desativadas; nenhuma mensagem é enviada ou excluída automaticamente. +O modo local usa uma identidade fixa e só funciona com `NODE_ENV=development`, `AUTH_MODE=local` e API em loopback. Nunca exponha esse modo na rede. Em produção, a API valida tokens com Supabase Auth e restringe tarefas e histórico ao usuário autenticado. A configuração de deploy está em [deployment](docs/DEPLOYMENT.md). Integrações de e-mail permanecem desativadas. Consulte [AGENTS.md](AGENTS.md), [arquitetura](docs/ARCHITECTURE.md), [segurança](docs/SECURITY.md) e [roadmap](docs/ROADMAP.md). diff --git a/apps/api/README.md b/apps/api/README.md index 4dcb2eb..e6a9b5d 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -19,7 +19,7 @@ A migração inicial cria `Task`, `TaskActivity`, enums, índices e a chave estr - `PATCH /api/v1/tasks/:id` - `DELETE /api/v1/tasks/:id` -Autenticação e autorização por usuário ainda não estão implementadas. Não exponha a API com dados pessoais. +A API exige um Bearer token validado pelo Supabase Auth e restringe todos os acessos ao dono da tarefa. O modo local exige configuração explícita de desenvolvimento. Consulte [security](../../docs/SECURITY.md) e [deployment](../../docs/DEPLOYMENT.md). ## Testes de persistência diff --git a/apps/api/package.json b/apps/api/package.json index 3702e0f..f6f8cf8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1 +1 @@ -{"name":"@taskdesk/api","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"tsx watch src/server.ts","build":"prisma generate && tsc -p tsconfig.build.json","start":"node dist/server.js","typecheck":"prisma generate && tsc --noEmit -p tsconfig.json","test":"vitest run","db:generate":"prisma generate","db:migrate":"prisma migrate dev","db:deploy":"prisma migrate deploy","db:studio":"prisma studio"},"dependencies":{"@prisma/client":"^6.19.1","dotenv":"^16.4.7","express":"^5.1.0","zod":"^3.25.76"},"devDependencies":{"@types/express":"^5.0.1","@types/node":"^22.13.0","@types/supertest":"^6.0.2","prisma":"^6.19.1","supertest":"^7.0.0","tsx":"^4.19.2","vitest":"^2.1.9"}} \ No newline at end of file +{"name":"@taskdesk/api","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"tsx watch src/server.ts","build":"prisma generate && tsc -p tsconfig.build.json","start":"node dist/server.js","typecheck":"prisma generate && tsc --noEmit -p tsconfig.json","test":"vitest run","db:generate":"prisma generate","db:migrate":"prisma migrate dev","db:deploy":"prisma migrate deploy","db:studio":"prisma studio"},"dependencies":{"@prisma/adapter-pg":"7.10.1","@prisma/client":"7.10.1","@supabase/supabase-js":"2.119.0","dotenv":"^16.4.7","express":"^5.1.0","zod":"4.6.7"},"devDependencies":{"@types/express":"^5.0.1","@types/node":"^22.13.0","@types/supertest":"^6.0.2","prisma":"7.10.1","supertest":"^7.0.0","tsx":"^4.19.2","vitest":"5.1.0"}} \ No newline at end of file diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts new file mode 100644 index 0000000..d103dbe --- /dev/null +++ b/apps/api/prisma.config.ts @@ -0,0 +1,11 @@ +import { config } from "dotenv"; +import { defineConfig } from "prisma/config"; + +config({ path: new URL("../../.env", import.meta.url) }); + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { path: "prisma/migrations" }, + // Client generation works without runtime secrets; migration commands reject an empty URL. + datasource: { url: process.env.DATABASE_URL ?? "" }, +}); diff --git a/apps/api/prisma/migrations/20261111234150_task_ownership/migration.sql b/apps/api/prisma/migrations/20261111234150_task_ownership/migration.sql new file mode 100644 index 0000000..85af48a --- /dev/null +++ b/apps/api/prisma/migrations/20261111234150_task_ownership/migration.sql @@ -0,0 +1,20 @@ +-- DropIndex +DROP INDEX "Task_dueAt_idx"; + +-- DropIndex +DROP INDEX "Task_status_priority_idx"; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "ownerId" UUID; + +-- CreateIndex +CREATE INDEX "Task_ownerId_status_priority_idx" ON "Task"("ownerId", "status", "priority"); + +-- CreateIndex +CREATE INDEX "Task_ownerId_dueAt_idx" ON "Task"("ownerId", "dueAt"); + +-- Prisma is the only data access boundary. Deny direct Data API access by default. +-- The trusted server database role needs BYPASSRLS; do not give that role to clients. +ALTER TABLE "Task" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TaskActivity" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "_prisma_migrations" ENABLE ROW LEVEL SECURITY; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml index 2fe25d8..044d57c 100644 --- a/apps/api/prisma/migrations/migration_lock.toml +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -1 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7ff2b02..2af744c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } enum TaskStatus { @@ -22,6 +21,7 @@ enum TaskPriority { } model Task { + ownerId String? @db.Uuid id String @id @default(cuid()) title String @db.VarChar(160) description String? @db.Text @@ -34,8 +34,8 @@ model Task { updatedAt DateTime @updatedAt activities TaskActivity[] - @@index([status, priority]) - @@index([dueAt]) + @@index([ownerId, status, priority]) + @@index([ownerId, dueAt]) } model TaskActivity { diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index e2166a5..3e39f55 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -19,7 +19,7 @@ describe("createApp",()=>{ afterEach(()=>vi.restoreAllMocks()); it("returns security headers and does not cache API responses",async()=>{ - const response=await request(createApp({taskRepository:createRepository()})).get("/health"); + const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()})).get("/health"); expect(response.status).toBe(200); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["cache-control"]).toBe("no-store"); @@ -28,7 +28,7 @@ describe("createApp",()=>{ it("returns a generic error without logging the underlying error message",async()=>{ const errorSpy=vi.spyOn(console,"error").mockImplementation(()=>undefined); - const response=await request(createApp({taskRepository:createRepository()})) + const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()})) .post("/api/v1/tasks") .send({title:"Tarefa válida"}); @@ -40,7 +40,7 @@ describe("createApp",()=>{ it("rejects oversized JSON as a client error without logging task data",async()=>{ const errorSpy=vi.spyOn(console,"error").mockImplementation(()=>undefined); - const response=await request(createApp({taskRepository:createRepository()})) + const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()})) .post("/api/v1/tasks").send({title:"x".repeat(110000)}); expect(response.status).toBe(413); expect(response.body).toEqual({error:"O corpo da requisição excede o limite permitido."}); @@ -48,7 +48,7 @@ describe("createApp",()=>{ }); it("rejects malformed JSON with a safe client error",async()=>{ - const response=await request(createApp({taskRepository:createRepository()})) + const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()})) .post("/api/v1/tasks") .set("Content-Type","application/json") .send('{"title":'); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9250bad..8b12845 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,27 +1,61 @@ -import express,{type ErrorRequestHandler,type Request,type Response} from "express"; -import { PrismaClient } from "@prisma/client"; +import express, { type ErrorRequestHandler } from "express"; +import { createPrismaClient } from "./database/prisma.js"; +import { createAuthenticator, AuthenticationUnavailable, type Authenticate } from "./auth/authentication.js"; import { PrismaTaskRepository } from "./tasks/prisma-task.repository.js"; import { createTasksRouter } from "./tasks/tasks.routes.js"; import type { TaskRepository } from "./tasks/task.types.js"; -export interface AppOptions { taskRepository?:TaskRepository; } -export function createApp(options:AppOptions={}) { - const app=express(); - const prisma=options.taskRepository?null:new PrismaClient(); - const repository=options.taskRepository??new PrismaTaskRepository(prisma!); - app.disable("x-powered-by"); - app.use((request:Request,response:Response,next)=>{response.setHeader("X-Content-Type-Options","nosniff");response.setHeader("Cache-Control","no-store");next();}); - app.use(express.json({limit:"100kb"})); - app.get("/health",(_request:Request,response:Response)=>response.status(200).json({status:"ok",service:"taskdesk-api",timestamp:new Date().toISOString()})); - app.get("/api/v1",(_request:Request,response:Response)=>response.status(200).json({name:"taskDesk API",version:"v1",status:"available"})); - app.use("/api/v1/tasks",createTasksRouter(repository)); - const errorHandler:ErrorRequestHandler=(error:unknown,_request,response,_next)=>{ - if(typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large"){ - response.status(413).json({error:"O corpo da requisição excede o limite permitido."});return; - } - if(error instanceof SyntaxError && "body" in error){response.status(400).json({error:"O corpo da requisição contém JSON inválido."});return;} - console.error("Unhandled API error"); - response.status(500).json({error:"Não foi possível concluir a operação."}); - }; - app.use(errorHandler); - return app; + +export interface AppOptions { + taskRepository?: TaskRepository; + repositoryForUser?: (userId: string) => TaskRepository; + authenticate?: Authenticate; +} + +export function createApp(options: AppOptions = {}) { + const app = express(); + let prisma: ReturnType | undefined; + const authenticate = options.authenticate ?? createAuthenticator(); + const repositoryForUser = options.repositoryForUser ?? ((userId: string) => + options.taskRepository ?? new PrismaTaskRepository(prisma ??= createPrismaClient(), userId)); + + app.disable("x-powered-by"); + app.use((_request, response, next) => { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Cache-Control", "no-store"); + next(); + }); + app.use(express.json({ limit: "100kb" })); + app.get("/health", (_request, response) => response.status(200).json({ + status: "ok", service: "taskdesk-api", timestamp: new Date().toISOString(), + })); + app.get("/api/v1", (_request, response) => response.status(200).json({ + name: "taskDesk API", version: "v1", status: "available", + })); + app.use("/api/v1/tasks", async (request, response, next) => { + const identity = await authenticate(request); + if (!identity) { + response.status(401).json({ error: "Entre na sua conta para continuar." }); + return; + } + return createTasksRouter(repositoryForUser(identity.id))(request, response, next); + }); + + const errorHandler: ErrorRequestHandler = (error: unknown, _request, response, _next) => { + if (error instanceof AuthenticationUnavailable) { + response.status(503).json({ error: "O acesso está temporariamente indisponível." }); + return; + } + if (typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large") { + response.status(413).json({ error: "O corpo da requisição excede o limite permitido." }); + return; + } + if (error instanceof SyntaxError && "body" in error) { + response.status(400).json({ error: "O corpo da requisição contém JSON inválido." }); + return; + } + console.error("Unhandled API error"); + response.status(500).json({ error: "Não foi possível concluir a operação." }); + }; + app.use(errorHandler); + return app; } diff --git a/apps/api/src/auth/authentication.test.ts b/apps/api/src/auth/authentication.test.ts new file mode 100644 index 0000000..250f8ba --- /dev/null +++ b/apps/api/src/auth/authentication.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import request from "supertest"; +import { createApp } from "../app.js"; +import { createAuthenticator } from "./authentication.js"; +import type { TaskRepository } from "../tasks/task.types.js"; + +const owner = "11111111-1111-4111-8111-111111111111"; +const repository = { + list: vi.fn(async () => []), findById: async () => null, listActivities: async () => [], + create: async () => { throw new Error(); }, update: async () => null, delete: async () => false, +} satisfies TaskRepository; +const env = {SUPABASE_URL:"https://auth.example.test", SUPABASE_PUBLISHABLE_KEY:"sb_publishable_test"}; + +describe("authentication boundary", () => { + afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + it("denies anonymous, malformed and invalid bearer tokens before reading tasks", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({msg:"Invalid JWT",code:"bad_jwt"}), {status:401})); + vi.stubGlobal("fetch", fetchMock); + const app = createApp({taskRepository:repository, authenticate:createAuthenticator(env)}); + expect((await request(app).get("/api/v1/tasks")).status).toBe(401); + expect((await request(app).get("/api/v1/tasks").set("Authorization","Basic abc")).status).toBe(401); + expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer invalid")).status).toBe(401); + expect(repository.list).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("uses the verified user identity, never supplied ownership metadata", async () => { + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response(JSON.stringify({id:owner,is_anonymous:false,user_metadata:{ownerId:"attacker"}}),{status:200}))); + const factory=vi.fn(()=>repository); + const app=createApp({repositoryForUser:factory,authenticate:createAuthenticator(env)}); + expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer verified-token")).status).toBe(200); + expect(factory).toHaveBeenCalledWith(owner); + }); + it("fails closed when auth is missing or unavailable and keeps health available",async()=>{ + const missing=createApp({taskRepository:repository,authenticate:createAuthenticator({})}); + expect((await request(missing).get("/api/v1/tasks")).status).toBe(503); + expect((await request(missing).get("/health")).status).toBe(200); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response('{}',{status:503}))); + const unavailable=createApp({taskRepository:repository,authenticate:createAuthenticator(env)}); + expect((await request(unavailable).get("/api/v1/tasks").set("Authorization","Bearer token")).status).toBe(503); + expect(repository.list).not.toHaveBeenCalled(); + }); + it("rejects Supabase anonymous accounts",async()=>{ + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response(JSON.stringify({id:owner,is_anonymous:true}),{status:200}))); + const app=createApp({taskRepository:repository,authenticate:createAuthenticator(env)}); + expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer token")).status).toBe(401); + }); + it("forbids development bypass in production or Vercel",()=>{ + expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"production"})).toThrow(); + expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"development",VERCEL:"1"})).toThrow(); + expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"development"})).not.toThrow(); + }); +}); diff --git a/apps/api/src/auth/authentication.ts b/apps/api/src/auth/authentication.ts new file mode 100644 index 0000000..3c91392 --- /dev/null +++ b/apps/api/src/auth/authentication.ts @@ -0,0 +1,38 @@ +import { createClient } from "@supabase/supabase-js"; +import type { Request } from "express"; + +export interface Identity { id: string; } +export type Authenticate = (request: Request) => Promise; +export const LOCAL_USER_ID = "00000000-0000-4000-8000-000000000001"; + +export class AuthenticationUnavailable extends Error {} + +export function createAuthenticator(env: NodeJS.ProcessEnv = process.env): Authenticate { + if (env.AUTH_MODE === "local") { + if (env.NODE_ENV !== "development" || env.VERCEL) { + throw new Error("Local authentication is allowed only in local development."); + } + return async () => ({ id: LOCAL_USER_ID }); + } + if (env.AUTH_MODE && env.AUTH_MODE !== "supabase") throw new Error("Invalid AUTH_MODE."); + const url = env.SUPABASE_URL; + const key = env.SUPABASE_PUBLISHABLE_KEY; + if (!url || !key) return async () => { throw new AuthenticationUnavailable(); }; + if (!key.startsWith("sb_publishable_")) throw new Error("Use a Supabase publishable key."); + const client = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, + global: { fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(8000) }) }, + }); + return async request => { + const match = /^Bearer ([^\s]+)$/i.exec(request.get("Authorization") ?? ""); + if (!match) return null; + const { data, error } = await client.auth.getUser(match[1]); + if (error) { + if (error.status === undefined || error.status >= 500) throw new AuthenticationUnavailable(); + return null; + } + // Never derive ownership from user-editable metadata or decode an unverified JWT. + if (!data.user || data.user.is_anonymous) return null; + return { id: data.user.id }; + }; +} diff --git a/apps/api/src/database/prisma.ts b/apps/api/src/database/prisma.ts new file mode 100644 index 0000000..eec64e2 --- /dev/null +++ b/apps/api/src/database/prisma.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +export function createPrismaClient(connectionString = process.env.DATABASE_URL): PrismaClient { + if (!connectionString) throw new Error("DATABASE_URL is required."); + const adapter = new PrismaPg({ + connectionString, + max: 5, + connectionTimeoutMillis: 5000, + idleTimeoutMillis: 10000, + }); + return new PrismaClient({ adapter }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0dfcb87..98ffe62 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,4 +1,5 @@ -import "dotenv/config"; +import { config } from "dotenv"; +config({path:new URL("../../../.env",import.meta.url)}); import { createApp } from "./app.js"; const port = Number(process.env.API_PORT ?? 3000); @@ -8,6 +9,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("API_PORT must be an integer between 1 and 65535."); } +if (process.env.AUTH_MODE === "local" && !["127.0.0.1", "::1", "localhost"].includes(host)) { + throw new Error("Local authentication requires a loopback API_HOST."); +} + const app = createApp(); app.listen(port, host, () => { diff --git a/apps/api/src/tasks/prisma-task.repository.integration.test.ts b/apps/api/src/tasks/prisma-task.repository.integration.test.ts index 33c892d..9e182bf 100644 --- a/apps/api/src/tasks/prisma-task.repository.integration.test.ts +++ b/apps/api/src/tasks/prisma-task.repository.integration.test.ts @@ -1,12 +1,14 @@ +import request from "supertest"; +import { createApp } from "../app.js"; import { randomUUID } from "node:crypto"; -import { PrismaClient } from "@prisma/client"; +import { createPrismaClient } from "../database/prisma.js"; import { afterAll, describe, expect, it } from "vitest"; import { PrismaTaskRepository } from "./prisma-task.repository.js"; // Use only a disposable database; migrations must be applied before this suite. describe.skipIf(!process.env.TEST_DATABASE_URL)("Prisma task persistence", () => { - const prisma = new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL }); - const repository = new PrismaTaskRepository(prisma); + const prisma = createPrismaClient(process.env.TEST_DATABASE_URL ?? "postgresql://localhost/unused"); + const repository = new PrismaTaskRepository(prisma, randomUUID()); const ids: string[] = []; const title = `integration-${randomUUID()}`; afterAll(async () => { @@ -56,4 +58,56 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)("Prisma task persistence", () => expect(await repository.findById(task.id)).toMatchObject({status:"IN_PROGRESS", priority:"URGENT"}); expect(await repository.listActivities(task.id)).toHaveLength(3); }); + it("isolates every HTTP task operation between users and quarantines legacy rows", async () => { + const alice = randomUUID(); + const bob = randomUUID(); + const app = createApp({ + authenticate: async req => { + const token = req.get("Authorization"); + return token === "Bearer alice" ? {id:alice} : token === "Bearer bob" ? {id:bob} : null; + }, + repositoryForUser: userId => new PrismaTaskRepository(prisma,userId), + }); + const created=await request(app).post("/api/v1/tasks").set("Authorization","Bearer alice").send({title}); + expect(created.status).toBe(201); + const id=created.body.data.id as string; + ids.push(id); + const legacy=await prisma.task.create({data:{title}}); + ids.push(legacy.id); + expect((await request(app).get("/api/v1/tasks")).status).toBe(401); + for(const token of ["alice","bob"]){ + const list=await request(app).get("/api/v1/tasks").set("Authorization",`Bearer ${token}`); + expect(list.body.data.map((task: {id:string})=>task.id)).toEqual(token==="alice"?[id]:[]); + expect((await request(app).get(`/api/v1/tasks/${legacy.id}`).set("Authorization",`Bearer ${token}`)).status).toBe(404); + } + for(const suffix of ["", "/activities"]){ + expect((await request(app).get(`/api/v1/tasks/${id}${suffix}`).set("Authorization","Bearer bob")).status).toBe(404); + } + expect((await request(app).patch(`/api/v1/tasks/${id}`).set("Authorization","Bearer bob").send({status:"COMPLETED"})).status).toBe(404); + expect((await request(app).delete(`/api/v1/tasks/${id}`).set("Authorization","Bearer bob")).status).toBe(404); + expect((await request(app).patch(`/api/v1/tasks/${id}`).set("Authorization","Bearer alice").send({ownerId:bob})).status).toBe(400); + expect((await request(app).patch(`/api/v1/tasks/${id}`).set("Authorization","Bearer alice").send({status:"COMPLETED"})).status).toBe(200); + const history=await request(app).get(`/api/v1/tasks/${id}/activities`).set("Authorization","Bearer alice"); + expect(history.body.data).toHaveLength(2); + expect((await request(app).delete(`/api/v1/tasks/${id}`).set("Authorization","Bearer alice")).status).toBe(204); + }); + it("denies direct table reads to a non-privileged Data API style role",async()=>{ + await create(); + const role=`taskdesk_test_${randomUUID().replaceAll("-","")}`; + await prisma.$executeRawUnsafe(`CREATE ROLE "${role}" NOLOGIN`); + try { + await prisma.$executeRawUnsafe(`GRANT USAGE ON SCHEMA public TO "${role}"`); + await prisma.$executeRawUnsafe(`GRANT SELECT ON "Task", "TaskActivity", "_prisma_migrations" TO "${role}"`); + await prisma.$transaction(async tx=>{ + await tx.$executeRawUnsafe(`SET LOCAL ROLE "${role}"`); + expect(await tx.task.findMany()).toEqual([]); + expect(await tx.taskActivity.findMany()).toEqual([]); + expect(await tx.$queryRaw`SELECT * FROM "_prisma_migrations"`).toEqual([]); + }); + } finally { + await prisma.$executeRawUnsafe(`DROP OWNED BY "${role}"`); + await prisma.$executeRawUnsafe(`DROP ROLE "${role}"`); + } + }); + }); diff --git a/apps/api/src/tasks/prisma-task.repository.ts b/apps/api/src/tasks/prisma-task.repository.ts index d09ee32..79c6bd6 100644 --- a/apps/api/src/tasks/prisma-task.repository.ts +++ b/apps/api/src/tasks/prisma-task.repository.ts @@ -13,22 +13,22 @@ import type { } from "./task.types.js"; export class PrismaTaskRepository implements TaskRepository { - constructor(private readonly prisma: PrismaClient) {} + constructor(private readonly prisma: PrismaClient, private readonly ownerId: string) {} async list(filters: ListTaskFilters): Promise { return this.prisma.task.findMany({ - where: filters, + where: { ...filters, ownerId: this.ownerId }, orderBy: [{ dueAt: "asc" }, { createdAt: "desc" }], }); } async findById(id: string): Promise { - return this.prisma.task.findUnique({ where: { id } }); + return this.prisma.task.findFirst({ where: { id, ownerId: this.ownerId } }); } async listActivities(taskId: string): Promise { return this.prisma.taskActivity.findMany({ - where: { taskId }, + where: { taskId, task: { ownerId: this.ownerId } }, orderBy: { createdAt: "asc" }, }); } @@ -37,6 +37,7 @@ export class PrismaTaskRepository implements TaskRepository { return this.prisma.task.create({ data: { ...input, + ownerId: this.ownerId, activities: { create: { type: "CREATED", message: "Tarefa criada." } }, }, }); @@ -47,10 +48,10 @@ export class PrismaTaskRepository implements TaskRepository { for (let attempt = 0; ; attempt++) { try { return await this.prisma.$transaction(async transaction => { - const before = await transaction.task.findUnique({ where: { id } }); + const before = await transaction.task.findFirst({ where: { id, ownerId: this.ownerId } }); if (!before) return null; - const task = await transaction.task.update({ where: { id }, data: input }); + const task = await transaction.task.update({ where: { id, ownerId: this.ownerId }, data: input }); const activities = (Object.keys(input) as (keyof UpdateTaskInput)[]) .map(field => activityMessage(field, before, task)) .filter((message): message is string => message !== null) @@ -70,7 +71,7 @@ export class PrismaTaskRepository implements TaskRepository { } async delete(id: string): Promise { - const result = await this.prisma.task.deleteMany({ where: { id } }); + const result = await this.prisma.task.deleteMany({ where: { id, ownerId: this.ownerId } }); return result.count > 0; } } diff --git a/apps/api/src/tasks/tasks.routes.test.ts b/apps/api/src/tasks/tasks.routes.test.ts index 0234d23..bb2ab30 100644 --- a/apps/api/src/tasks/tasks.routes.test.ts +++ b/apps/api/src/tasks/tasks.routes.test.ts @@ -25,7 +25,7 @@ class InMemoryTaskRepository implements TaskRepository { async delete(id:string):Promise{const count=this.tasks.length;this.tasks=this.tasks.filter(task=>task.id!==id);return this.tasks.length{ - const repository=new InMemoryTaskRepository();const app=createApp({taskRepository:repository}); + const repository=new InMemoryTaskRepository();const app=createApp({authenticate:async()=>({id:"test-user"}),taskRepository:repository}); it("creates a task with safe defaults",async()=>{ const response=await request(app).post("/api/v1/tasks").send({title:" Preparar relatório "}); expect(response.status).toBe(201);expect(response.body.data).toMatchObject({title:"Preparar relatório",status:"NEW",priority:"NORMAL",isDemo:false}); @@ -74,4 +74,25 @@ describe("task endpoints",()=>{ expect(invalidDate.status).toBe(400); expect(unexpectedQuery.status).toBe(400); }); -}); \ No newline at end of file +}); + +describe("task validation contract",()=>{ + it.each([ + {title:"x".repeat(161)}, + {title:"Valid",description:"x".repeat(5001)}, + {title:"Valid",dueAt:"2026-02-30T12:00:00.000Z"}, + {title:"Valid",ownerId:"11111111-1111-4111-8111-111111111111"}, + {title:42}, + ])("rejects invalid boundary input without changing the error response contract",async payload=>{ + const app=createApp({authenticate:async()=>({id:"test-user"}),taskRepository:new InMemoryTaskRepository()}); + const response=await request(app).post("/api/v1/tasks").send(payload); + expect(response.status).toBe(400); + expect(response.body).toMatchObject({error:"Dados da tarefa inválidos.",details:{formErrors:expect.any(Array),fieldErrors:expect.any(Object)}}); + }); + it("accepts maximum-length fields and offset timestamps",async()=>{ + const app=createApp({authenticate:async()=>({id:"test-user"}),taskRepository:new InMemoryTaskRepository()}); + const response=await request(app).post("/api/v1/tasks").send({title:"x".repeat(160),description:"x".repeat(5000),dueAt:"2026-11-11T12:00:00-03:00"}); + expect(response.status).toBe(201); + expect(response.body.data.dueAt).toBe("2026-11-11T15:00:00.000Z"); + }); +}); diff --git a/apps/api/src/tasks/tasks.routes.ts b/apps/api/src/tasks/tasks.routes.ts index fc6c95f..b821392 100644 --- a/apps/api/src/tasks/tasks.routes.ts +++ b/apps/api/src/tasks/tasks.routes.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { Router, type Request, type Response } from "express"; import type { TaskRepository } from "./task.types.js"; @@ -20,7 +21,7 @@ export function createTasksRouter(repository: TaskRepository): Router { if (!filters.success) { response.status(400).json({ error: "Filtros inválidos.", - details: filters.error.flatten(), + details: z.flattenError(filters.error), }); return; } @@ -33,7 +34,7 @@ export function createTasksRouter(repository: TaskRepository): Router { if (!input.success) { response.status(400).json({ error: "Dados da tarefa inválidos.", - details: input.error.flatten(), + details: z.flattenError(input.error), }); return; } @@ -66,7 +67,7 @@ export function createTasksRouter(repository: TaskRepository): Router { if (!input.success) { response.status(400).json({ error: "Dados da tarefa inválidos.", - details: input.error.flatten(), + details: z.flattenError(input.error), }); return; } diff --git a/apps/web/api/index.mjs b/apps/web/api/index.mjs new file mode 100644 index 0000000..78b6e19 --- /dev/null +++ b/apps/web/api/index.mjs @@ -0,0 +1,4 @@ +import { createApp } from "../../api/dist/app.js"; + +// A single application/Prisma pool is reused across warm function invocations. +export default createApp(); diff --git a/apps/web/package.json b/apps/web/package.json index 8eebd6e..e286e3f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,20 +4,21 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "vite --host 127.0.0.1", "build": "vue-tsc -b && vite build", "typecheck": "vue-tsc -b --pretty", "test": "vitest run" }, "dependencies": { + "@supabase/supabase-js": "2.119.0", "vue": "^3.5.0" }, "devDependencies": { - "@vue/test-utils": "^2.4.6", - "@vitejs/plugin-vue": "^5.2.1", + "@vitejs/plugin-vue": "6.0.10", + "@vue/test-utils": "2.5.1", "jsdom": "^25.0.1", - "vite": "^6.0.0", - "vitest": "^2.1.8", - "vue-tsc": "^2.2.0" + "vite": "8.3.8", + "vitest": "5.1.0", + "vue-tsc": "3.3.14" } } diff --git a/apps/web/src/App.spec.ts b/apps/web/src/App.spec.ts index 6187580..84e1945 100644 --- a/apps/web/src/App.spec.ts +++ b/apps/web/src/App.spec.ts @@ -1,343 +1,50 @@ import { flushPromises, mount } from "@vue/test-utils"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App.vue"; -type TaskStatus = "NEW" | "IN_PROGRESS" | "WAITING" | "COMPLETED"; -type TaskPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT"; - -interface Task { - id: string; - title: string; - description: string | null; - status: TaskStatus; - priority: TaskPriority; - dueAt: string | null; - origin: string | null; - isDemo: boolean; - createdAt: string; - updatedAt: string; -} - -function makeTask(overrides: Partial = {}): Task { - return { - id: "task-12345678", - title: "Tarefa de teste", - description: null, - status: "NEW", - priority: "NORMAL", - dueAt: null, - origin: null, - isDemo: false, - createdAt: "2026-11-01T12:00:00.000Z", - updatedAt: "2026-11-01T12:00:00.000Z", - ...overrides, - }; -} - -function jsonResponse(data: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - json: async () => data, - } as Response; -} - -let fetchMock: ReturnType; - -async function renderApp() { - const wrapper = mount(App); - await flushPromises(); - return wrapper; -} - -describe("task dashboard", () => { - beforeEach(() => { - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date("2026-11-08T12:00:00.000Z")); - fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [] })); - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("does not present unavailable task data as an empty workspace and retries loading", async () => { - fetchMock - .mockRejectedValueOnce(new Error("API indisponível")) - .mockResolvedValueOnce(jsonResponse({ data: [] })); - - const wrapper = await renderApp(); - - expect(wrapper.text()).toContain("API indisponível"); - expect(wrapper.find(".empty-state").text()).toContain("Não foi possível carregar as tarefas."); - expect(wrapper.text()).not.toContain("Seu espaço ainda está vazio."); - expect(wrapper.find(".offline-badge").exists()).toBe(true); - - await wrapper.find(".feedback-error .text-button").trigger("click"); - await flushPromises(); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(wrapper.text()).toContain("Seu espaço ainda está vazio."); - expect(wrapper.find(".live-badge").exists()).toBe(true); - }); - - it("combines status and priority filters", async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ - data: [ - makeTask({ id: "task-1", title: "Alta em andamento", status: "IN_PROGRESS", priority: "HIGH" }), - makeTask({ id: "task-2", title: "Normal em andamento", status: "IN_PROGRESS", priority: "NORMAL" }), - makeTask({ id: "task-3", title: "Alta concluída", status: "COMPLETED", priority: "HIGH" }), - ], - })); - - const wrapper = await renderApp(); - - await wrapper.find("#status-filter").setValue("IN_PROGRESS"); - await wrapper.find("#priority-filter").setValue("HIGH"); - - expect(wrapper.findAll(".task-card")).toHaveLength(1); - expect(wrapper.text()).toContain("Alta em andamento"); - expect(wrapper.text()).not.toContain("Normal em andamento"); - expect(wrapper.text()).not.toContain("Alta concluída"); - }); - - it("clearly identifies tasks marked as demonstration data", async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ - data: [makeTask({ id: "task-demo", title: "Tarefa demonstrativa", isDemo: true })], - })); - - const wrapper = await renderApp(); - - expect(wrapper.find(".demo-task-badge").text()).toBe("Demonstração"); - }); - - it("shows overdue and upcoming due dates but ignores completed tasks", async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ - data: [ - makeTask({ id: "task-overdue", title: "Prazo vencido", dueAt: "2026-11-07T12:00:00.000Z" }), - makeTask({ id: "task-soon", title: "Prazo próximo", dueAt: "2026-11-12T12:00:00.000Z" }), - makeTask({ id: "task-done", title: "Tarefa concluída", status: "COMPLETED", dueAt: "2026-11-07T12:00:00.000Z" }), - ], - })); - - const wrapper = await renderApp(); - const taskCards = wrapper.findAll(".task-card"); - - expect(wrapper.findAll(".due-indicator")).toHaveLength(2); - expect(wrapper.text()).toContain("Atrasada"); - expect(wrapper.text()).toContain("Prazo próximo"); - expect(taskCards[2]?.find(".due-indicator").exists()).toBe(false); - }); - - it("creates a task and displays the success feedback", async () => { - const createdTask = makeTask({ - id: "task-created", - title: "Revisar relatório", - priority: "HIGH", - }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [] })) - .mockResolvedValueOnce(jsonResponse({ data: createdTask })); - - const wrapper = await renderApp(); - await wrapper.find("#task-title").setValue(" Revisar relatório "); - await wrapper.find("#task-priority").setValue("HIGH"); - await wrapper.find("form.task-form").trigger("submit"); - await flushPromises(); - - expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/v1/tasks", expect.objectContaining({ - method: "POST", - body: expect.stringContaining('"title":"Revisar relatório"'), - })); - expect(wrapper.text()).toContain("Revisar relatório"); - expect(wrapper.text()).toContain("Tarefa criada com sucesso."); - }); - - it("edits a task and sends normalized values", async () => { - const original = makeTask({ id: "task-edit", title: "Título antigo" }); - const updated = makeTask({ id: "task-edit", title: "Título atualizado", priority: "URGENT" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [original] })) - .mockResolvedValueOnce(jsonResponse({ data: updated })); - - const wrapper = await renderApp(); - await wrapper.find(".task-card .secondary-button").trigger("click"); - await wrapper.find("#edit-title-task-edit").setValue(" Título atualizado "); - await wrapper.find("#edit-priority-task-edit").setValue("URGENT"); - await wrapper.find("form.edit-form").trigger("submit"); - await flushPromises(); - - expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/v1/tasks/task-edit", expect.objectContaining({ - method: "PATCH", - body: expect.stringContaining('"title":"Título atualizado"'), - })); - expect(wrapper.text()).toContain("Título atualizado"); - expect(wrapper.text()).toContain("Tarefa atualizada."); - expect(wrapper.find("form.edit-form").exists()).toBe(false); - }); - - it("keeps the status update error visible after refreshing the task list", async () => { - const task = makeTask({ id: "task-status-error", status: "NEW" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [task] })) - .mockResolvedValueOnce(jsonResponse({ error: "Falha ao atualizar status." }, 500)) - .mockResolvedValueOnce(jsonResponse({ data: [task] })); - - const wrapper = await renderApp(); - await wrapper.find(".task-status-select").setValue("IN_PROGRESS"); - await flushPromises(); - - expect(wrapper.text()).toContain("Falha ao atualizar status."); - expect(wrapper.find(".feedback-error .text-button").exists()).toBe(false); - }); - - it("does not present stale tasks as current when a failed update is followed by a failed refresh", async () => { - const task = makeTask({ id: "task-stale", title: "Dados anteriores" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [task] })) - .mockResolvedValueOnce(jsonResponse({ error: "Falha ao atualizar status." }, 500)) - .mockRejectedValueOnce(new Error("API indisponível")); - - const wrapper = await renderApp(); - expect(wrapper.findAll(".task-card")).toHaveLength(1); - - await wrapper.find(".task-status-select").setValue("IN_PROGRESS"); - await flushPromises(); - - expect(wrapper.findAll(".task-card")).toHaveLength(0); - expect(wrapper.text()).not.toContain("Dados anteriores"); - expect(wrapper.text()).toContain("Dados indisponíveis"); - expect(wrapper.text()).toContain("Não foi possível carregar as tarefas."); - expect(wrapper.text()).toContain("API indisponível"); - }); - - it("updates a task status and refreshes its visible state", async () => { - const original = makeTask({ id: "task-status", status: "NEW" }); - const updated = makeTask({ id: "task-status", status: "IN_PROGRESS" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [original] })) - .mockResolvedValueOnce(jsonResponse({ data: updated })); - - const wrapper = await renderApp(); - await wrapper.find(".task-status-select").setValue("IN_PROGRESS"); - await flushPromises(); - - expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/v1/tasks/task-status", expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ status: "IN_PROGRESS" }), - })); - expect(wrapper.text()).toContain("Status atualizado."); - }); - - it("loads task history and labels legacy update events", async () => { - const task = makeTask({ id: "task-history" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [task] })) - .mockResolvedValueOnce(jsonResponse({ - data: [ - { id: "activity-1", taskId: task.id, type: "CREATED", message: "Tarefa criada.", createdAt: "2026-11-08T10:00:00.000Z" }, - { id: "activity-2", taskId: task.id, type: "UPDATED", message: "Tarefa atualizada.", createdAt: "2026-11-08T11:00:00.000Z" }, - { id: "activity-3", taskId: task.id, type: "FIELD_UPDATED", message: "Prioridade: Normal → Alta", createdAt: "2026-11-08T12:00:00.000Z" }, - ], - })); - - const wrapper = await renderApp(); - await wrapper.findAll("button").find((button) => button.text() === "Histórico")?.trigger("click"); - await flushPromises(); - - expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/v1/tasks/task-history/activities", expect.anything()); - expect(wrapper.text()).toContain("Criada"); - expect(wrapper.text()).toContain("Atualizada"); - expect(wrapper.text()).toContain("Alteração"); - expect(wrapper.text()).toContain("Prioridade: Normal → Alta"); - }); - - it("keeps history errors scoped to the task whose request failed", async () => { - const first = makeTask({ id: "task-history-first", title: "Primeira tarefa" }); - const second = makeTask({ id: "task-history-second", title: "Segunda tarefa" }); - let rejectFirstHistory!: (reason: Error) => void; - const firstHistory = new Promise((_resolve, reject) => { - rejectFirstHistory = reject; - }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [first, second] })) - .mockImplementationOnce(() => firstHistory) - .mockResolvedValueOnce(jsonResponse({ - data: [{ id: "activity-second", taskId: second.id, type: "CREATED", message: "Histórico da segunda tarefa.", createdAt: "2026-11-08T10:00:00.000Z" }], - })); - - const wrapper = await renderApp(); - const cards = wrapper.findAll(".task-card"); - const firstHistoryButton = cards[0]!.findAll("button").find((button) => button.text() === "Histórico")!; - const secondHistoryButton = cards[1]!.findAll("button").find((button) => button.text() === "Histórico")!; - - await firstHistoryButton.trigger("click"); - await secondHistoryButton.trigger("click"); - await flushPromises(); - rejectFirstHistory(new Error("Falha no histórico da primeira tarefa.")); - await flushPromises(); - - const secondCard = wrapper.findAll(".task-card")[1]!; - expect(secondCard.text()).toContain("Histórico da segunda tarefa."); - expect(secondCard.find(".activity-error").exists()).toBe(false); - }); - - it("offers a retry when task history cannot be loaded", async () => { - const task = makeTask({ id: "task-history-retry" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [task] })) - .mockRejectedValueOnce(new Error("Histórico indisponível")) - .mockResolvedValueOnce(jsonResponse({ - data: [{ id: "activity-retry", taskId: task.id, type: "CREATED", message: "Tarefa criada.", createdAt: "2026-11-08T10:00:00.000Z" }], - })); - - const wrapper = await renderApp(); - await wrapper.findAll("button").find((button) => button.text() === "Histórico")?.trigger("click"); - await flushPromises(); - - expect(wrapper.text()).toContain("Histórico indisponível"); - expect(wrapper.text()).not.toContain("Nenhuma atividade registrada."); - - await wrapper.find(".activity-error .text-button").trigger("click"); - await flushPromises(); - - expect(fetchMock).toHaveBeenCalledTimes(3); - expect(wrapper.text()).toContain("Tarefa criada."); - }); - - it("does not delete a task when confirmation is cancelled", async () => { - const task = makeTask({ id: "task-protected", title: "Manter tarefa" }); - fetchMock.mockResolvedValueOnce(jsonResponse({ data: [task] })); - const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); - - const wrapper = await renderApp(); - await wrapper.find(".danger-button").trigger("click"); - await flushPromises(); - - expect(confirm).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(wrapper.text()).toContain("Manter tarefa"); - confirm.mockRestore(); - }); - - it("deletes a task only after confirmation", async () => { - const task = makeTask({ id: "task-delete", title: "Excluir tarefa" }); - fetchMock - .mockResolvedValueOnce(jsonResponse({ data: [task] })) - .mockResolvedValueOnce(jsonResponse(null, 204)); - const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); - - const wrapper = await renderApp(); - await wrapper.find(".danger-button").trigger("click"); - await flushPromises(); - - expect(fetchMock).toHaveBeenNthCalledWith(2, "/api/v1/tasks/task-delete", expect.objectContaining({ - method: "DELETE", - })); - expect(wrapper.text()).not.toContain("Excluir tarefa"); - expect(wrapper.text()).toContain("Tarefa excluída."); - confirm.mockRestore(); - }); +const mocks=vi.hoisted(()=>({ + onAuthStateChange:vi.fn(), signInWithPassword:vi.fn(), signOut:vi.fn(), unsubscribe:vi.fn(), +})); +vi.mock("./auth",()=>({localMode:false,authClient:{auth:mocks}})); +vi.mock("./TaskDashboard.vue",()=>({default:{template:'
Tarefas privadas
'}})); +let notify: (event:string,session:unknown)=>void; +const session={access_token:"token",user:{id:"user-1"}}; + +describe("account access",()=>{ + beforeEach(()=>{ + vi.clearAllMocks(); + mocks.onAuthStateChange.mockImplementation(callback=>{ + notify=callback; + callback("INITIAL_SESSION",null); + return {data:{subscription:{unsubscribe:mocks.unsubscribe}}}; + }); + }); + it("keeps tasks hidden until a successful sign-in and clears the password",async()=>{ + mocks.signInWithPassword.mockResolvedValue({data:{session},error:null}); + const wrapper=mount(App);await flushPromises(); + expect(wrapper.find('[data-testid="dashboard"]').exists()).toBe(false); + await wrapper.find("#login-email").setValue("person@example.test"); + await wrapper.find("#login-password").setValue("test-password"); + await wrapper.find("form").trigger("submit");await flushPromises(); + expect(mocks.signInWithPassword).toHaveBeenCalledWith({email:"person@example.test",password:"test-password"}); + expect(wrapper.text()).toContain("Tarefas privadas"); + mocks.signOut.mockResolvedValue({error:null}); + await wrapper.find("button").trigger("click");await flushPromises(); + expect(wrapper.find('[data-testid="dashboard"]').exists()).toBe(false); + expect((wrapper.find("#login-password").element as HTMLInputElement).value).toBe(""); + wrapper.unmount();expect(mocks.unsubscribe).toHaveBeenCalledOnce(); + }); + it("shows generic login failures and removes private views on session expiry",async()=>{ + mocks.signInWithPassword.mockResolvedValue({error:new Error("sensitive provider details")}); + const wrapper=mount(App);await flushPromises(); + await wrapper.find("form").trigger("submit");await flushPromises(); + expect(wrapper.text()).toContain("Não foi possível entrar."); + expect(wrapper.text()).not.toContain("sensitive provider details"); + notify("SIGNED_IN",session);await flushPromises(); + expect(wrapper.text()).toContain("Tarefas privadas"); + window.dispatchEvent(new Event("taskdesk:unauthorized"));await flushPromises(); + expect(wrapper.find('[data-testid="dashboard"]').exists()).toBe(false); + expect(wrapper.text()).toContain("Sua sessão expirou."); + wrapper.unmount(); + }); }); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 9d6129b..edc6b8d 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1,442 +1,96 @@