From 0bc64369fc540d44b09aac4a19ed325121bb308f Mon Sep 17 00:00:00 2001 From: voltzinho Date: Wed, 11 Nov 2026 23:46:40 +0000 Subject: [PATCH 1/5] feat(auth): protect task access with Supabase identity and per-user ownership --- .env.example | 11 + .github/workflows/ci.yml | 2 +- apps/api/README.md | 2 +- apps/api/package.json | 2 +- .../migration.sql | 20 + .../api/prisma/migrations/migration_lock.toml | 2 + apps/api/prisma/schema.prisma | 5 +- apps/api/src/app.test.ts | 8 +- apps/api/src/app.ts | 78 ++- apps/api/src/auth/authentication.test.ts | 52 ++ apps/api/src/auth/authentication.ts | 38 ++ apps/api/src/server.ts | 7 +- ...prisma-task.repository.integration.test.ts | 56 +- apps/api/src/tasks/prisma-task.repository.ts | 15 +- apps/api/src/tasks/tasks.routes.test.ts | 2 +- apps/web/api/index.mjs | 4 + apps/web/package.json | 5 +- apps/web/src/App.spec.ts | 385 ++------------ apps/web/src/App.vue | 486 +++--------------- apps/web/src/TaskDashboard.spec.ts | 343 ++++++++++++ apps/web/src/TaskDashboard.vue | 445 ++++++++++++++++ apps/web/src/auth.ts | 20 + apps/web/src/style.css | 7 + apps/web/vercel.json | 8 + apps/web/vite.config.ts | 1 + package-lock.json | 174 +++++-- package.json | 2 +- 27 files changed, 1341 insertions(+), 839 deletions(-) create mode 100644 apps/api/prisma/migrations/20261111234150_task_ownership/migration.sql create mode 100644 apps/api/src/auth/authentication.test.ts create mode 100644 apps/api/src/auth/authentication.ts create mode 100644 apps/web/api/index.mjs create mode 100644 apps/web/src/TaskDashboard.spec.ts create mode 100644 apps/web/src/TaskDashboard.vue create mode 100644 apps/web/src/auth.ts create mode 100644 apps/web/vercel.json 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..1fa0d0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ 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: Generate Prisma Client run: npm run db:generate 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..9070eb5 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/client":"^6.19.1","@supabase/supabase-js":"2.119.0","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 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..2ba89e5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -22,6 +22,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 +35,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..6a5ba87 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 express, { type ErrorRequestHandler } from "express"; import { PrismaClient } from "@prisma/client"; +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(); + const prisma = options.taskRepository || options.repositoryForUser ? null : new PrismaClient(); + const authenticate = options.authenticate ?? createAuthenticator(); + const repositoryForUser = options.repositoryForUser ?? ((userId: string) => + options.taskRepository ?? new PrismaTaskRepository(prisma!, 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/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..b73f152 100644 --- a/apps/api/src/tasks/prisma-task.repository.integration.test.ts +++ b/apps/api/src/tasks/prisma-task.repository.integration.test.ts @@ -1,3 +1,5 @@ +import request from "supertest"; +import { createApp } from "../app.js"; import { randomUUID } from "node:crypto"; import { PrismaClient } from "@prisma/client"; import { afterAll, describe, expect, it } from "vitest"; @@ -6,7 +8,7 @@ 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 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..f3973f2 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}); 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..3777bd7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,17 +4,18 @@ "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", + "@vue/test-utils": "^2.4.6", "jsdom": "^25.0.1", "vite": "^6.0.0", "vitest": "^2.1.8", 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 @@