From 784069baf8bba1ab9b85f8c646115b215dbff31d Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 18:13:23 -0300 Subject: [PATCH 01/18] =?UTF-8?q?docs(it5):=20open=20It5-T01=20branch=20?= =?UTF-8?q?=E2=80=94=20update=20CURRENT-STATE=20+=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks branch feat/007-it5-instrutor-auth as active. CURRENT-STATE.md reflects the 12-task T01 plan (INSTRUTOR auth hardening), promotes the auth gap to P1 in the incomplete table, and extends the auth.ts key files entry. CLAUDE.md updated with It5 tech context via agent script. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 8 ++++++-- docs/CURRENT-STATE.md | 39 ++++++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 764c60ee..f4d665ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,12 @@ # PWeb_Project Development Guidelines -Auto-generated from all feature plans. Last updated: 2026-04-19 +Auto-generated from all feature plans. Last updated: 2026-04-21 ## Active Technologies +- TypeScript 5 (strict mode, `useUnknownInCatchVariables`) + Next.js 15 App Router, Supabase Auth SSR (`@supabase/ssr`), Prisma 7, Zod 3 (feat/007-it5-instrutor-auth) +- PostgreSQL via Prisma (no migration for this spec) (feat/007-it5-instrutor-auth) + - TypeScript 5 (strict mode) + Playwright 1.59, Next.js 15 App Router, Supabase local CLI (feat/006-it4-instrutor-e2e) - PostgreSQL via Prisma (local Supabase, ports 54321/54322) (feat/006-it4-instrutor-e2e) @@ -42,10 +45,11 @@ TypeScript 5 (strict mode, `useUnknownInCatchVariables` enabled): Follow standar ## Recent Changes +- feat/007-it5-instrutor-auth: Added TypeScript 5 (strict mode, `useUnknownInCatchVariables`) + Next.js 15 App Router, Supabase Auth SSR (`@supabase/ssr`), Prisma 7, Zod 3 + - feat/006-it4-instrutor-e2e: Added TypeScript 5 (strict mode) + Playwright 1.59, Next.js 15 App Router, Supabase local CLI - 004-elite-workflow-setup: Added TypeScript 5 (strict mode, `useUnknownInCatchVariables`) + Node.js 20 + Next.js 15 App Router, Prisma 7, Supabase SSR, Zod 3, Genkit 1.31 -- fix/telemetry-and-e2e-stability: Modernized Sentry to v10 + Next.js 15, implemented Privacy-First Replay, linked Supabase User IDs to observability, and repaired ESLint flat config. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 00f7618b..ec59b6e5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,8 +1,8 @@ # Current State — Five Star Academy -**Last Updated**: 2026-04-20 -**Branch**: `main` (v0.7.0 — It4 merged PR #73) -**Version**: 0.7.0 (It4 complete — INSTRUTOR workflow E2E, 18/18 passing) +**Last Updated**: 2026-04-21 +**Branch**: `feat/007-it5-instrutor-auth` (It5 — T01 in progress) +**Version**: 0.7.0 (It4 base — It5 hardening not yet merged) ## What Works Today @@ -31,13 +31,34 @@ | Structured logging | ✅ Done | `src/lib/logger.ts` (Logger wrapper, Sentry-aware) | | Dependencies | ✅ Updated | All patch/minor bumped via PR #70; lockfile clean | +## It5 Progress (007-it5-instrutor-auth) + +**Spec**: `specs/007-it5-instrutor-auth/` — all Phase 0–1 artifacts complete (spec, plan, research, data-model, quickstart, tasks) +**12 tasks planned** — implementation not yet started + +| Task | Description | Status | +| ---- | ------------------------------------------------------------ | ---------- | +| T001 | Add `requireAnyRole` to `src/lib/auth.ts` | ⏳ Pending | +| T002 | Create failing E2E negative test | ⏳ Pending | +| T003 | Gate `/dashboard/treinos` with `requireAnyRole` | ⏳ Pending | +| T004 | Write failing unit tests for `upsertTreinoAction` | ⏳ Pending | +| T005 | Remove `instrutorId` from `TreinoBaseSchema` | ⏳ Pending | +| T006 | Harden `upsertTreinoAction` (derive instrutorId server-side) | ⏳ Pending | +| T007 | Remove `instrutorId` prop from `TreinosManagementClient` | ⏳ Pending | +| T008 | Remove `getUser` + `instrutorId` prop from `TreinosPage` | ⏳ Pending | +| T009 | Write failing ownership unit tests | ⏳ Pending | +| T010 | Add ownership check to `updateTreinoDayAction` | ⏳ Pending | +| T011 | Add ownership check to `deleteTreinoAction` | ⏳ Pending | +| T012 | Update `CRITICAL-PATHS.md` 18 → 19 scenarios | ⏳ Pending | + ## What Is Incomplete -| Area | Gap | Priority | -| ------------- | ----------------------------------------------------------------------- | -------- | -| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | -| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | -| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | +| Area | Gap | Priority | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | +| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | +| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | +| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | ## Quality Gates (current status) @@ -69,7 +90,7 @@ npm run build → ✅ production build succeeds | `src/app/dashboard/` | Admin portal pages | | `src/app/aluno/` | Student portal pages | | `src/lib/actions/` | Server Actions (auth, alunos, treinos, financeiro) | -| `src/lib/auth.ts` | `requireRole()` helper | +| `src/lib/auth.ts` | `requireRole()` + `requireAnyRole()` (It5 pending) | | `src/services/` | Business logic (XP, streaks) | | `src/ai/flows/` | Genkit AI flows | | `prisma/schema.prisma` | DB schema | From 5da5811c649993ee7f310f970bd62f5c6ad67ac2 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 18:15:18 -0300 Subject: [PATCH 02/18] docs(it5): document pre-existing TS2882 regression in quality gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit globals.css side-effect import errors since src/types/css.d.ts was deleted in PR #80 (TypeScript 6 upgrade). Tracked as P2 — must be fixed before any It5 PR merges to main. Co-Authored-By: Claude Sonnet 4.6 --- docs/CURRENT-STATE.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ec59b6e5..d1adc178 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -53,17 +53,19 @@ ## What Is Incomplete -| Area | Gap | Priority | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | -| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | -| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | -| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | +| Area | Gap | Priority | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | +| **TS2882** | `src/types/css.d.ts` deleted in PR #80 TS6 upgrade; `globals.css` side-effect import errors in typecheck | P2 — fix before merge | +| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | +| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | +| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | ## Quality Gates (current status) ``` -npm run typecheck → ✅ 0 errors +npm run typecheck → ⚠️ 1 pre-existing error: TS2882 globals.css side-effect import + (src/types/css.d.ts deleted in PR #80 TypeScript 6 upgrade) npm run lint → ✅ 0 errors npm run test → ✅ 22/22 passing npm run e2e → ✅ 18/18 passing (local, 2026-04-19 — It4) From 95ee4d67e4b15b29e764801829ee222e9cefb265 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 18:17:55 -0300 Subject: [PATCH 03/18] fix(typecheck): restore src/types/css.d.ts deleted in PR #80 TS6 upgrade TS2882 side-effect import error on globals.css was introduced when the TS6 upgrade PR accidentally removed this ambient declaration file. One line: declare module '*.css' {} Co-Authored-By: Claude Sonnet 4.6 --- src/types/css.d.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/types/css.d.ts diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 00000000..a839446c --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1 @@ +declare module '*.css' {} From e12a927be08d0b1f85e43825737135da7a6c708e Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 18:19:18 -0300 Subject: [PATCH 04/18] docs(it5): mark TS2882 resolved in CURRENT-STATE quality gates Co-Authored-By: Claude Sonnet 4.6 --- docs/CURRENT-STATE.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d1adc178..103e09cb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -53,19 +53,17 @@ ## What Is Incomplete -| Area | Gap | Priority | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | -| **TS2882** | `src/types/css.d.ts` deleted in PR #80 TS6 upgrade; `globals.css` side-effect import errors in typecheck | P2 — fix before merge | -| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | -| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | -| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | +| Area | Gap | Priority | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | +| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | +| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | +| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | ## Quality Gates (current status) ``` -npm run typecheck → ⚠️ 1 pre-existing error: TS2882 globals.css side-effect import - (src/types/css.d.ts deleted in PR #80 TypeScript 6 upgrade) +npm run typecheck → ✅ 0 errors (TS2882 fixed: src/types/css.d.ts restored) npm run lint → ✅ 0 errors npm run test → ✅ 22/22 passing npm run e2e → ✅ 18/18 passing (local, 2026-04-19 — It4) From f5a27a54870f83b93fd32fb2180df13191a04518 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:13:06 -0300 Subject: [PATCH 05/18] fix(it5): add requireAnyRole helper to auth.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T001 — exports requireAnyRole(allowedRoles: Role[]): Promise following the exact same fail-closed pattern as requireRole. redirects to /login if unauthenticated, /dashboard if role is not in allowedRoles or on any DB error. needed by the /dashboard/treinos route gate. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/auth.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1fc65708..66e12804 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,39 @@ import { createClient } from '@/utils/supabase/server'; import { Logger } from '@/lib/logger'; import type { Role } from '@/lib/definitions'; +/** + * Asserts that the current user holds one of the allowed roles. + * Redirects to '/login' if unauthenticated; to '/dashboard' if unauthorized + * or on any DB error (fail-closed). + */ +export async function requireAnyRole(allowedRoles: Role[]): Promise { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect('/login'); + return; + } + + const { data, error } = await supabase + .from('funcionarios') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + if (error) { + Logger.error(`[requireAnyRole] DB error fetching role: ${error.message}`, error); + redirect('/dashboard'); + } + + if (!data || !allowedRoles.includes(data.role)) { + redirect('/dashboard'); + } +} + /** * Asserts that the currently authenticated user has the required role. * Redirects to '/login' if no session exists. From 1746b750a1efca4b459f3ee1ca1d10be278dd7b2 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:16:19 -0300 Subject: [PATCH 06/18] fix(it5): add failing E2E negative auth test (TDD red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T002 — instrutor-auth-negative.spec.ts asserts RECEPCIONISTA and ALUNO cannot access /dashboard/treinos (must redirect). tests fail until T003 adds the requireAnyRole gate to the page — that is the intended red phase. also anchors /specs/ in .gitignore to root so tests/e2e/specs/ is no longer inadvertently excluded. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 +-- .../e2e/specs/instrutor-auth-negative.spec.ts | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/specs/instrutor-auth-negative.spec.ts diff --git a/.gitignore b/.gitignore index 4fce906f..82dd20a3 100644 --- a/.gitignore +++ b/.gitignore @@ -109,8 +109,8 @@ walkthrough.md .remember/ .specify/ -# Feature planning artifacts -specs/ +# Feature planning artifacts (root-level only — not tests/e2e/specs/) +/specs/ # Dev scripts (not part of the build) scripts/ diff --git a/tests/e2e/specs/instrutor-auth-negative.spec.ts b/tests/e2e/specs/instrutor-auth-negative.spec.ts new file mode 100644 index 00000000..d07d3058 --- /dev/null +++ b/tests/e2e/specs/instrutor-auth-negative.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { loginAs, logout } from '../helpers/auth'; + +test.describe('INSTRUTOR auth — negative scenarios', () => { + test('RECEPCIONISTA cannot access /dashboard/treinos — redirected to /dashboard', async ({ + page, + }) => { + await loginAs(page, 'RECEPCIONISTA'); + await page.goto('/dashboard/treinos'); + await page.waitForURL((url) => !url.pathname.startsWith('/dashboard/treinos'), { + timeout: 15_000, + }); + await expect(page).toHaveURL(/\/dashboard(?!\/treinos)/); + }); + + test('ALUNO cannot access /dashboard/treinos — redirected away', async ({ page }) => { + await loginAs(page, 'ALUNO'); + await page.goto('/dashboard/treinos'); + await page.waitForURL((url) => !url.pathname.startsWith('/dashboard/treinos'), { + timeout: 15_000, + }); + expect(page.url()).not.toContain('/dashboard/treinos'); + }); + + test.afterEach(async ({ page }) => { + await logout(page); + }); +}); From 30e1742e686002c5f91ed6d7fa19d6fa52ad90b6 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:17:52 -0300 Subject: [PATCH 07/18] fix(it5): gate /dashboard/treinos with requireAnyRole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T003 — replaces bare if(!user) check with requireAnyRole(['INSTRUTOR', 'GERENTE']). RECEPCIONISTA and ALUNO are now redirected fail-closed to /dashboard. getUser() call kept temporarily for instrutorId={user.id} prop; removed in T008 after client cleanup. Co-Authored-By: Claude Sonnet 4.6 --- src/app/dashboard/treinos/page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx index 6907d07c..9c885e77 100644 --- a/src/app/dashboard/treinos/page.tsx +++ b/src/app/dashboard/treinos/page.tsx @@ -1,18 +1,19 @@ import { prisma } from '@/lib/prisma'; import { createClient } from '@/utils/supabase/server'; +import { requireAnyRole } from '@/lib/auth'; import { PageHeader } from '@/components/page-header'; import TreinosManagementClient from './treinos-client'; import type { Aluno } from '@/lib/definitions'; export default async function TreinosPage() { + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); - if (!user) { - return
Não autorizado
; - } + if (!user) return null; // Buscar todos os alunos para a seleção via Prisma const alunosPrisma = await prisma.aluno.findMany({ From 0dd89990074e7364490707b27ae2033c68b9d5b8 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:21:20 -0300 Subject: [PATCH 08/18] fix(it5): add failing unit tests for treinos actions (TDD red) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T004 + T009 — treinos.test.ts covers upsertTreinoAction (instrutorId derivation) and ownership checks for deleteTreinoAction and updateTreinoDayAction. 4 tests currently fail (red phase): INSTRUTOR instrutorId derivation, RECEPCIONISTA blocked, and both non-owner ownership guards. tests written before implementation as required by constitution principle III. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/treinos.test.ts | 222 ++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/lib/actions/treinos.test.ts diff --git a/src/lib/actions/treinos.test.ts b/src/lib/actions/treinos.test.ts new file mode 100644 index 00000000..30fb195c --- /dev/null +++ b/src/lib/actions/treinos.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })); +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); +vi.mock('@/utils/supabase/server', () => ({ createClient: vi.fn() })); +vi.mock('@/lib/prisma', () => ({ + prisma: { + treino: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +import { upsertTreinoAction, updateTreinoDayAction, deleteTreinoAction } from './treinos'; +import { createClient } from '@/utils/supabase/server'; +import { prisma } from '@/lib/prisma'; + +const mockCreateClient = vi.mocked(createClient); +const mockTreino = vi.mocked(prisma.treino); + +const INSTRUTOR_UUID = '00000000-0000-0000-0000-000000000003'; +const GERENTE_UUID = '00000000-0000-0000-0000-000000000001'; +const RECEP_UUID = '00000000-0000-0000-0000-000000000002'; +const ALUNO_UUID = '00000000-0000-0000-0000-000000000004'; +const TREINO_UUID = '00000000-0000-0000-0000-000000000099'; + +const BASE_PAYLOAD = { + alunoId: ALUNO_UUID, + objetivo: 'Hipertrofia', + diaSemana: 1 as number | null, + exercicios: [{ nomeExercicio: 'Supino', series: 3, repeticoes: '10' }], +}; + +function buildSupabaseMock(userId: string | null, role: string | null, dbError = false) { + return { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: userId ? { id: userId } : null }, + error: null, + }), + }, + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi + .fn() + .mockResolvedValue( + dbError + ? { data: null, error: new Error('db error') } + : { data: role ? { role } : null, error: null } + ), + }), + }), + }), + }; +} + +// ─── upsertTreinoAction ──────────────────────────────────────────────────── + +describe('upsertTreinoAction — instrutorId derivation', () => { + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockTreino.create.mockResolvedValue({ id: TREINO_UUID } as any); + }); + + it('INSTRUTOR: prisma.treino.create called with instrutorId = session user.id', async () => { + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await upsertTreinoAction(BASE_PAYLOAD); + + expect(result).toEqual({ success: true }); + expect(mockTreino.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ instrutorId: INSTRUTOR_UUID }), + }) + ); + }); + + it('GERENTE: prisma.treino.create called with instrutorId = null', async () => { + const supabase = buildSupabaseMock(GERENTE_UUID, 'GERENTE'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await upsertTreinoAction(BASE_PAYLOAD); + + expect(result).toEqual({ success: true }); + expect(mockTreino.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ instrutorId: null }), + }) + ); + }); + + it('RECEPCIONISTA: returns Acesso não autorizado', async () => { + const supabase = buildSupabaseMock(RECEP_UUID, 'RECEPCIONISTA'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await upsertTreinoAction(BASE_PAYLOAD); + + expect(result).toEqual({ success: false, error: 'Acesso não autorizado' }); + expect(mockTreino.create).not.toHaveBeenCalled(); + }); + + it('unauthenticated: returns Usuário não autenticado', async () => { + const supabase = buildSupabaseMock(null, null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + + const result = await upsertTreinoAction(BASE_PAYLOAD); + + expect(result).toEqual({ success: false, error: 'Usuário não autenticado' }); + expect(mockTreino.create).not.toHaveBeenCalled(); + }); +}); + +// ─── deleteTreinoAction — ownership ─────────────────────────────────────── + +describe('deleteTreinoAction — ownership check', () => { + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.delete.mockResolvedValue({ id: TREINO_UUID } as any); + }); + + it('INSTRUTOR who owns the treino: delete succeeds', async () => { + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: INSTRUTOR_UUID } as any); + + const result = await deleteTreinoAction(TREINO_UUID); + + expect(result).toEqual({ success: true }); + expect(mockTreino.delete).toHaveBeenCalled(); + }); + + it('INSTRUTOR who does NOT own the treino: returns Acesso não autorizado', async () => { + const OTHER_INSTRUTOR = '00000000-0000-0000-0000-000000000099'; + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: OTHER_INSTRUTOR } as any); + + const result = await deleteTreinoAction(TREINO_UUID); + + expect(result).toEqual({ success: false, error: 'Acesso não autorizado' }); + expect(mockTreino.delete).not.toHaveBeenCalled(); + }); + + it('GERENTE: delete succeeds regardless of treino owner', async () => { + const supabase = buildSupabaseMock(GERENTE_UUID, 'GERENTE'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // treino owned by someone else — GERENTE overrides + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: INSTRUTOR_UUID } as any); + + const result = await deleteTreinoAction(TREINO_UUID); + + expect(result).toEqual({ success: true }); + expect(mockTreino.delete).toHaveBeenCalled(); + }); +}); + +// ─── updateTreinoDayAction — ownership ──────────────────────────────────── + +describe('updateTreinoDayAction — ownership check', () => { + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.update.mockResolvedValue({ id: TREINO_UUID } as any); + }); + + it('INSTRUTOR who owns the treino: update succeeds', async () => { + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: INSTRUTOR_UUID } as any); + + const result = await updateTreinoDayAction(TREINO_UUID, 2); + + expect(result).toEqual({ success: true }); + expect(mockTreino.update).toHaveBeenCalled(); + }); + + it('INSTRUTOR who does NOT own the treino: returns Acesso não autorizado', async () => { + const OTHER_INSTRUTOR = '00000000-0000-0000-0000-000000000099'; + const supabase = buildSupabaseMock(INSTRUTOR_UUID, 'INSTRUTOR'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: OTHER_INSTRUTOR } as any); + + const result = await updateTreinoDayAction(TREINO_UUID, 2); + + expect(result).toEqual({ success: false, error: 'Acesso não autorizado' }); + expect(mockTreino.update).not.toHaveBeenCalled(); + }); + + it('GERENTE: update succeeds regardless of treino owner', async () => { + const supabase = buildSupabaseMock(GERENTE_UUID, 'GERENTE'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCreateClient.mockResolvedValue(supabase as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockTreino.findUnique.mockResolvedValue({ instrutorId: INSTRUTOR_UUID } as any); + + const result = await updateTreinoDayAction(TREINO_UUID, 2); + + expect(result).toEqual({ success: true }); + expect(mockTreino.update).toHaveBeenCalled(); + }); +}); From 2a1bb927d479f123f6015601d4d2410f3535880a Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:30:44 -0300 Subject: [PATCH 09/18] fix(it5): remove instrutorId from TreinoBaseSchema (server-derived) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T005: instrutorId is now server-derived from session in upsertTreinoAction. Removed from TreinoBaseSchema (client input); preserved in TreinoSchema (entity read-type). Cascade errors in treinos.ts:35 and treinos-client.tsx:384,433 are intentional red state — fixed in T006/T007. Co-Authored-By: Claude Sonnet 4.6 --- src/app/aluno/meus-treinos/meus-treinos-client.tsx | 4 +--- src/lib/definitions.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.tsx index d0af42d3..4f049a6d 100644 --- a/src/app/aluno/meus-treinos/meus-treinos-client.tsx +++ b/src/app/aluno/meus-treinos/meus-treinos-client.tsx @@ -73,9 +73,8 @@ export default function MeusTreinosClient({ try { const res = await upsertTreinoAction({ ...treinoData, - id: editingTreino?.id, + ...(editingTreino ? { id: editingTreino.id } : {}), alunoId: userId, - instrutorId: userId, }); if (res.success) { @@ -192,7 +191,6 @@ export default function MeusTreinosClient({ const res = await upsertTreinoAction({ alunoId: userId, - instrutorId: undefined, objetivo: workout.nome, exercicios: novosExercicios, diaSemana: isDayOccupied ? null : diaSugerido, diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts index f701b6f2..a3f838a5 100644 --- a/src/lib/definitions.ts +++ b/src/lib/definitions.ts @@ -55,7 +55,7 @@ export type Exercicio = z.infer; export const TreinoBaseSchema = z.object({ alunoId: z.string().uuid('ID do aluno inválido'), - instrutorId: z.string().uuid('ID do instrutor inválido').optional(), + // instrutorId is server-derived from session — not accepted from client objetivo: z.string().min(3, 'Objetivo é obrigatório'), dataCriacao: z.string().or(z.date()).optional(), /** Dia da semana (0 = Domingo, 1 = Segunda, ..., 6 = Sábado). Null se não estiver ativo. */ @@ -65,6 +65,7 @@ export const TreinoBaseSchema = z.object({ export const TreinoSchema = TreinoBaseSchema.extend({ id: z.string().uuid('ID inválido'), + instrutorId: z.string().uuid().optional().nullable(), // Note: Here we update exercicios to be the Entity version if we are fetching from DB exercicios: z.array(ExercicioSchema).min(1), }); From 650febc5ecae101ceccddbcfb3619c7e88ed823b Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:32:40 -0300 Subject: [PATCH 10/18] fix(it5): derive instrutorId from session in upsertTreinoAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T006: instrutorId is now fetched from the funcionarios table instead of the client payload. RECEPCIONISTA is blocked (Acesso não autorizado). ALUNO (not in funcionarios → data=null) and GERENTE both receive instrutorId=null. INSTRUTOR receives their own user.id. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/treinos.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index c9da5fa2..09758143 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -21,7 +21,21 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & data: { user }, error: authError, } = await supabase.auth.getUser(); - if (authError || !user) throw new Error('Usuário não autenticado'); + if (authError || !user) return { success: false, error: 'Usuário não autenticado' }; + + const { data: funcData, error: roleError } = await supabase + .from('funcionarios') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + if (roleError) return { success: false, error: 'Erro ao verificar permissões' }; + + // RECEPCIONISTA is explicitly blocked; ALUNO (not in funcionarios) gets null + if (funcData?.role === 'RECEPCIONISTA') { + return { success: false, error: 'Acesso não autorizado' }; + } + const derivedInstrutorId = funcData?.role === 'INSTRUTOR' ? user.id : null; // Validação flexível: se tiver ID, valida como Entity; se não, como Base. let validatedData; @@ -32,7 +46,7 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & } // Extraímos os dados validados. 'id' será undefined se for Base. - const { alunoId, instrutorId, objetivo, exercicios, diaSemana } = validatedData; + const { alunoId, objetivo, exercicios, diaSemana } = validatedData; const id = 'id' in validatedData ? (validatedData as TreinoBase & { id: string }).id : undefined; @@ -60,7 +74,7 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & await prisma.treino.create({ data: { alunoId, - instrutorId: instrutorId || null, + instrutorId: derivedInstrutorId, objetivo, diaSemana, Exercicios: { From 751fcc781a76e33e6f89cd72a19b355962769ad4 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:34:28 -0300 Subject: [PATCH 11/18] fix(it5): remove instrutorId prop from TreinosManagementClient T007: instrutorId is no longer needed as a component prop since upsertTreinoAction now derives it server-side from session. Removed from props interface and both upsertTreinoAction call sites. Co-Authored-By: Claude Sonnet 4.6 --- src/app/dashboard/treinos/treinos-client.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/dashboard/treinos/treinos-client.tsx b/src/app/dashboard/treinos/treinos-client.tsx index 273affc4..519f099b 100644 --- a/src/app/dashboard/treinos/treinos-client.tsx +++ b/src/app/dashboard/treinos/treinos-client.tsx @@ -316,13 +316,7 @@ function PlanoGeradoParaEdicao({ ); } -export default function TreinosManagementClient({ - initialAlunos, - instrutorId, -}: { - initialAlunos: Aluno[]; - instrutorId: string; -}) { +export default function TreinosManagementClient({ initialAlunos }: { initialAlunos: Aluno[] }) { const { toast } = useToast(); const [selectedAlunoId, setSelectedAlunoId] = useState(null); const [objetivo, setObjetivo] = useState(''); @@ -381,7 +375,6 @@ export default function TreinosManagementClient({ try { const res = await upsertTreinoAction({ alunoId: selectedAlunoId, - instrutorId, objetivo, exercicios: exercicios as Exercicio[], diaSemana: null, @@ -430,7 +423,6 @@ export default function TreinosManagementClient({ for (const workout of planoEditado.workouts) { await upsertTreinoAction({ alunoId: selectedAluno.id, - instrutorId, objetivo: workout.nome, exercicios: workout.exercicios.map((ex) => ({ nomeExercicio: ex.nomeExercicio, From 0ff04c6e54de9991024cd9f28223d83bf8c8e97f Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:35:44 -0300 Subject: [PATCH 12/18] fix(it5): remove redundant getUser and instrutorId prop from TreinosPage T008: requireAnyRole already asserts authentication, so the subsequent supabase.auth.getUser() call and instrutorId={user.id} prop are removed. instrutorId is now fully derived server-side in upsertTreinoAction. Zero typecheck errors after this task. Co-Authored-By: Claude Sonnet 4.6 --- src/app/dashboard/treinos/page.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx index 9c885e77..4afbb082 100644 --- a/src/app/dashboard/treinos/page.tsx +++ b/src/app/dashboard/treinos/page.tsx @@ -1,5 +1,4 @@ import { prisma } from '@/lib/prisma'; -import { createClient } from '@/utils/supabase/server'; import { requireAnyRole } from '@/lib/auth'; import { PageHeader } from '@/components/page-header'; import TreinosManagementClient from './treinos-client'; @@ -8,13 +7,6 @@ import type { Aluno } from '@/lib/definitions'; export default async function TreinosPage() { await requireAnyRole(['INSTRUTOR', 'GERENTE']); - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return null; - // Buscar todos os alunos para a seleção via Prisma const alunosPrisma = await prisma.aluno.findMany({ orderBy: { nomeCompleto: 'asc' }, @@ -43,7 +35,7 @@ export default async function TreinosPage() { title="Gestão de Treinos" description="Monte treinos manualmente ou use a IA para gerar sugestões personalizadas para os alunos." /> - + ); } From cca2b40978a4ca20d5d9b7f5792af5e4fc95c9e2 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:36:26 -0300 Subject: [PATCH 13/18] fix(it5): add ownership check to updateTreinoDayAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T010: INSTRUTOR can only update treinos they own (instrutorId = user.id). GERENTE can update any treino. Unauthorized callers receive { success: false, error: 'Acesso não autorizado' }. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/treinos.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index 09758143..b0ff44d5 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -109,7 +109,22 @@ export async function updateTreinoDayAction(treinoId: string, diaSemana: number data: { user }, error: authError, } = await supabase.auth.getUser(); - if (authError || !user) throw new Error('Usuário não autenticado'); + if (authError || !user) return { success: false, error: 'Usuário não autenticado' }; + + const { data: funcData } = await supabase + .from('funcionarios') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + const treino = await prisma.treino.findUnique({ + where: { id: treinoId }, + select: { instrutorId: true }, + }); + + if (funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id) { + return { success: false, error: 'Acesso não autorizado' }; + } await prisma.treino.update({ where: { id: treinoId }, From 582f9e792b78c8a1637ccd8a67cc53b3d6a041fb Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:37:23 -0300 Subject: [PATCH 14/18] fix(it5): add ownership check to deleteTreinoAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T011: mirrors T010 pattern — INSTRUTOR can only delete treinos they own; GERENTE can delete any treino. All 10 unit tests green. Zero typecheck and lint errors. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/treinos.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index b0ff44d5..7eddeb85 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -145,7 +145,22 @@ export async function deleteTreinoAction(treinoId: string) { data: { user }, error: authError, } = await supabase.auth.getUser(); - if (authError || !user) throw new Error('Usuário não autenticado'); + if (authError || !user) return { success: false, error: 'Usuário não autenticado' }; + + const { data: funcData } = await supabase + .from('funcionarios') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + const treino = await prisma.treino.findUnique({ + where: { id: treinoId }, + select: { instrutorId: true }, + }); + + if (funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id) { + return { success: false, error: 'Acesso não autorizado' }; + } await prisma.treino.delete({ where: { id: treinoId }, From 40158d87fdd1e118943e89f2094b81ff27761089 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:39:02 -0300 Subject: [PATCH 15/18] =?UTF-8?q?chore(it5):=20update=20CRITICAL-PATHS.md?= =?UTF-8?q?=2018=20=E2=86=92=2019=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T012: added entry for instrutor-auth-negative.spec.ts (RECEPCIONISTA/ALUNO blocked from /dashboard/treinos). All 12 tasks for 007-it5-instrutor-auth are now complete. Co-Authored-By: Claude Sonnet 4.6 --- tests/e2e/CRITICAL-PATHS.md | 45 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/e2e/CRITICAL-PATHS.md b/tests/e2e/CRITICAL-PATHS.md index 933c604d..e1d0c31a 100644 --- a/tests/e2e/CRITICAL-PATHS.md +++ b/tests/e2e/CRITICAL-PATHS.md @@ -2,30 +2,31 @@ **Suite**: Playwright — `tests/e2e/specs/` **Browser**: Chromium (headless) -**Total scenarios**: 18 +**Total scenarios**: 19 ## Coverage Table -| # | File | Scenario | Status | -| --- | ---------------------------- | ---------------------------------------------------------- | ---------- | -| 1 | `auth.spec.ts` | GERENTE login → redirects to /dashboard | ✅ Covered | -| 2 | `auth.spec.ts` | RECEPCIONISTA login → redirects to /dashboard | ✅ Covered | -| 3 | `auth.spec.ts` | ALUNO login → redirects to /aluno/dashboard | ✅ Covered | -| 4 | `auth.spec.ts` | Invalid credentials → stays on /login with error | ✅ Covered | -| 5 | `financial-access.spec.ts` | GERENTE accesses /dashboard/financeiro | ✅ Covered | -| 6 | `financial-access.spec.ts` | GERENTE accesses /dashboard/planos | ✅ Covered | -| 7 | `financial-access.spec.ts` | RECEPCIONISTA blocked from /dashboard/financeiro | ✅ Covered | -| 8 | `financial-access.spec.ts` | INSTRUTOR blocked from /dashboard/financeiro | ✅ Covered | -| 9 | `financial-access.spec.ts` | Unauthenticated → redirected to /login | ✅ Covered | -| 10 | `nav-visibility.spec.ts` | GERENTE sees Financeiro nav; RECEPCIONISTA does not | ✅ Covered | -| 11 | `nav-visibility.spec.ts` | GERENTE sees Planos nav; RECEPCIONISTA does not | ✅ Covered | -| 12 | `nav-visibility.spec.ts` | Admin nav absent from student portal | ✅ Covered | -| 13 | `student-portal.spec.ts` | ALUNO accesses /aluno/dashboard | ✅ Covered | -| 14 | `student-portal.spec.ts` | ALUNO blocked from admin /dashboard | ✅ Covered | -| 15 | `student-portal.spec.ts` | ALUNO accesses /aluno/meus-treinos | ✅ Covered | -| 16 | `workout-session.spec.ts` | ALUNO completes workout → AI feedback card shown | ✅ Covered | -| 17 | `enrollment.spec.ts` | GERENTE creates aluno → appears in list | ✅ Covered | -| 18 | `instrutor-workflow.spec.ts` | INSTRUTOR assigns workout → ALUNO sees "Do Personal" badge | ✅ Covered | +| # | File | Scenario | Status | +| --- | --------------------------------- | ---------------------------------------------------------- | ---------- | +| 1 | `auth.spec.ts` | GERENTE login → redirects to /dashboard | ✅ Covered | +| 2 | `auth.spec.ts` | RECEPCIONISTA login → redirects to /dashboard | ✅ Covered | +| 3 | `auth.spec.ts` | ALUNO login → redirects to /aluno/dashboard | ✅ Covered | +| 4 | `auth.spec.ts` | Invalid credentials → stays on /login with error | ✅ Covered | +| 5 | `financial-access.spec.ts` | GERENTE accesses /dashboard/financeiro | ✅ Covered | +| 6 | `financial-access.spec.ts` | GERENTE accesses /dashboard/planos | ✅ Covered | +| 7 | `financial-access.spec.ts` | RECEPCIONISTA blocked from /dashboard/financeiro | ✅ Covered | +| 8 | `financial-access.spec.ts` | INSTRUTOR blocked from /dashboard/financeiro | ✅ Covered | +| 9 | `financial-access.spec.ts` | Unauthenticated → redirected to /login | ✅ Covered | +| 10 | `nav-visibility.spec.ts` | GERENTE sees Financeiro nav; RECEPCIONISTA does not | ✅ Covered | +| 11 | `nav-visibility.spec.ts` | GERENTE sees Planos nav; RECEPCIONISTA does not | ✅ Covered | +| 12 | `nav-visibility.spec.ts` | Admin nav absent from student portal | ✅ Covered | +| 13 | `student-portal.spec.ts` | ALUNO accesses /aluno/dashboard | ✅ Covered | +| 14 | `student-portal.spec.ts` | ALUNO blocked from admin /dashboard | ✅ Covered | +| 15 | `student-portal.spec.ts` | ALUNO accesses /aluno/meus-treinos | ✅ Covered | +| 16 | `workout-session.spec.ts` | ALUNO completes workout → AI feedback card shown | ✅ Covered | +| 17 | `enrollment.spec.ts` | GERENTE creates aluno → appears in list | ✅ Covered | +| 18 | `instrutor-workflow.spec.ts` | INSTRUTOR assigns workout → ALUNO sees "Do Personal" badge | ✅ Covered | +| 19 | `instrutor-auth-negative.spec.ts` | RECEPCIONISTA/ALUNO blocked from /dashboard/treinos | ✅ Covered | ## Pending / Future Scenarios @@ -40,7 +41,7 @@ # Prerequisites: Docker running npm run supabase:start # start local Supabase (ports 54321/54322) npm run seed:e2e # create 4 deterministic test users -npm run e2e # run all 18 scenarios +npm run e2e # run all 19 scenarios npm run supabase:stop # cleanup ``` From 8bff4a27be9fdb624676a0741832b57db07348ef Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 19:43:21 -0300 Subject: [PATCH 16/18] docs: update CURRENT-STATE.md for It5 completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 12 tasks complete. Auth gap closed. Unit tests 22→32. E2E scenarios 18→19. Auth gap removed from incomplete list. Co-Authored-By: Claude Sonnet 4.6 --- docs/CURRENT-STATE.md | 103 +++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 52 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 103e09cb..3243fb0e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,72 +1,71 @@ # Current State — Five Star Academy **Last Updated**: 2026-04-21 -**Branch**: `feat/007-it5-instrutor-auth` (It5 — T01 in progress) -**Version**: 0.7.0 (It4 base — It5 hardening not yet merged) +**Branch**: `feat/007-it5-instrutor-auth` (It5 — all 12 tasks complete, PR ready) +**Version**: 0.7.0 (It4 base — It5 hardening pending merge) ## What Works Today -| Feature | Status | Notes | -| ------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | -| Admin login | ✅ Working | Supabase Auth SSR | -| Student login (Portal do Aluno) | ✅ Working | Separate session | -| Admin dashboard | ✅ Working | GERENTE + RECEPCIONISTA | -| Financial routes (`/financeiro`, `/planos`) | ✅ Working | GERENTE-only gate | -| Student workout view | ✅ Working | `meus-treinos` | -| AI workout generator | ✅ Working | Genkit + Gemini | -| Student enrollment | ✅ Working | Admin creates aluno | -| Gamification (XP, streaks) | ✅ Working | Hook `use-workout-tracker` | -| Prisma migrations | ✅ Tracked | `prisma/migrations/` | -| ESLint quality gate | ✅ Done | 0 errors — `any` + unused vars | -| TypeScript typecheck | ✅ Clean | 0 errors (strict mode) | -| AI workout feedback (US06) | ✅ Done | `WorkoutSession.tsx` — Genkit call + try/catch fallback + feedback card | -| Unit tests | ✅ Passing | 22/22 (Vitest) | -| Ops documentation | ✅ Done | Runbook, SLOs, threat model | -| Process documentation | ✅ Done | RFC + Postmortem templates | -| Local E2E stack | ✅ Done | `supabase start` (Docker) | -| E2E seed script | ✅ Done | `prisma/seed-e2e.ts` (5 fixtures: 4 users + 1 treino with 2 exercícios) | -| Playwright E2E suite | ✅ Done | 18 scenarios (+ instrutor-workflow: INSTRUTOR assigns workout → ALUNO sees "Do Personal" badge) | -| CI E2E job | ✅ Green | 18/18 passing locally (It4); 17/17 in last CI (PR #72); PR pending | -| Sentry error tracking | ✅ Active | DSN + auth token set in Vercel; source maps (92) uploaded on every build | -| Structured logging | ✅ Done | `src/lib/logger.ts` (Logger wrapper, Sentry-aware) | -| Dependencies | ✅ Updated | All patch/minor bumped via PR #70; lockfile clean | +| Feature | Status | Notes | +| ------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- | +| Admin login | ✅ Working | Supabase Auth SSR | +| Student login (Portal do Aluno) | ✅ Working | Separate session | +| Admin dashboard | ✅ Working | GERENTE + RECEPCIONISTA | +| Financial routes (`/financeiro`, `/planos`) | ✅ Working | GERENTE-only gate | +| Student workout view | ✅ Working | `meus-treinos` | +| AI workout generator | ✅ Working | Genkit + Gemini | +| Student enrollment | ✅ Working | Admin creates aluno | +| Gamification (XP, streaks) | ✅ Working | Hook `use-workout-tracker` | +| Prisma migrations | ✅ Tracked | `prisma/migrations/` | +| ESLint quality gate | ✅ Done | 0 errors — `any` + unused vars | +| TypeScript typecheck | ✅ Clean | 0 errors (strict mode) | +| AI workout feedback (US06) | ✅ Done | `WorkoutSession.tsx` — Genkit call + try/catch fallback + feedback card | +| Unit tests | ✅ Passing | 32/32 (Vitest — +10 for It5 treinos auth tests) | +| Ops documentation | ✅ Done | Runbook, SLOs, threat model | +| Process documentation | ✅ Done | RFC + Postmortem templates | +| Local E2E stack | ✅ Done | `supabase start` (Docker) | +| E2E seed script | ✅ Done | `prisma/seed-e2e.ts` (5 fixtures: 4 users + 1 treino with 2 exercícios) | +| Playwright E2E suite | ✅ Done | 19 scenarios (18 It4 + instrutor-auth-negative: RECEPCIONISTA/ALUNO blocked from /dashboard/treinos) | +| CI E2E job | ✅ Green | 18/18 passing locally (It4); 17/17 in last CI (PR #72); PR pending | +| Sentry error tracking | ✅ Active | DSN + auth token set in Vercel; source maps (92) uploaded on every build | +| Structured logging | ✅ Done | `src/lib/logger.ts` (Logger wrapper, Sentry-aware) | +| Dependencies | ✅ Updated | All patch/minor bumped via PR #70; lockfile clean | ## It5 Progress (007-it5-instrutor-auth) **Spec**: `specs/007-it5-instrutor-auth/` — all Phase 0–1 artifacts complete (spec, plan, research, data-model, quickstart, tasks) -**12 tasks planned** — implementation not yet started - -| Task | Description | Status | -| ---- | ------------------------------------------------------------ | ---------- | -| T001 | Add `requireAnyRole` to `src/lib/auth.ts` | ⏳ Pending | -| T002 | Create failing E2E negative test | ⏳ Pending | -| T003 | Gate `/dashboard/treinos` with `requireAnyRole` | ⏳ Pending | -| T004 | Write failing unit tests for `upsertTreinoAction` | ⏳ Pending | -| T005 | Remove `instrutorId` from `TreinoBaseSchema` | ⏳ Pending | -| T006 | Harden `upsertTreinoAction` (derive instrutorId server-side) | ⏳ Pending | -| T007 | Remove `instrutorId` prop from `TreinosManagementClient` | ⏳ Pending | -| T008 | Remove `getUser` + `instrutorId` prop from `TreinosPage` | ⏳ Pending | -| T009 | Write failing ownership unit tests | ⏳ Pending | -| T010 | Add ownership check to `updateTreinoDayAction` | ⏳ Pending | -| T011 | Add ownership check to `deleteTreinoAction` | ⏳ Pending | -| T012 | Update `CRITICAL-PATHS.md` 18 → 19 scenarios | ⏳ Pending | +**12 tasks — ALL COMPLETE** ✅ + +| Task | Description | Status | +| ---- | ------------------------------------------------------------- | ----------- | +| T001 | Add `requireAnyRole` to `src/lib/auth.ts` | ✅ Complete | +| T002 | Create E2E negative test (RECEPCIONISTA/ALUNO blocked) | ✅ Complete | +| T003 | Gate `/dashboard/treinos` with `requireAnyRole` | ✅ Complete | +| T004 | Write unit tests for `upsertTreinoAction` (TDD red) | ✅ Complete | +| T005 | Remove `instrutorId` from `TreinoBaseSchema` | ✅ Complete | +| T006 | Harden `upsertTreinoAction` (derive instrutorId from session) | ✅ Complete | +| T007 | Remove `instrutorId` prop from `TreinosManagementClient` | ✅ Complete | +| T008 | Remove `getUser` + `instrutorId` prop from `TreinosPage` | ✅ Complete | +| T009 | Write ownership unit tests (TDD red) | ✅ Complete | +| T010 | Add ownership check to `updateTreinoDayAction` | ✅ Complete | +| T011 | Add ownership check to `deleteTreinoAction` | ✅ Complete | +| T012 | Update `CRITICAL-PATHS.md` 18 → 19 scenarios | ✅ Complete | ## What Is Incomplete -| Area | Gap | Priority | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| **Auth gap** | `upsertTreinoAction` accepts client-supplied `instrutorId`; no ownership check on update/delete; `/dashboard/treinos` has no role gate | **P1 — It5 T01** | -| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | -| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | -| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | +| Area | Gap | Priority | +| ------------- | ----------------------------------------------------------------------- | -------- | +| CI security | 3 moderate vulns in `@prisma/dev` (transitive, awaiting upstream) | P3 | +| Lint warnings | `no-console` warnings reduced — remaining are accepted Logger internals | P3 | +| `@types/pg` | Pinned at `8.11.11` — dependabot PR #63 open, needs manual compat check | P3 | ## Quality Gates (current status) ``` npm run typecheck → ✅ 0 errors (TS2882 fixed: src/types/css.d.ts restored) npm run lint → ✅ 0 errors -npm run test → ✅ 22/22 passing -npm run e2e → ✅ 18/18 passing (local, 2026-04-19 — It4) +npm run test → ✅ 32/32 passing (+10 It5 auth tests) +npm run e2e → ⏳ 19 scenarios (18 confirmed It4; #19 needs E2E run against local stack) npm run build → ✅ production build succeeds ``` @@ -79,7 +78,7 @@ npm run build → ✅ production build succeeds - **Validation**: Zod 3 - **AI**: Google Genkit 1.32 + Gemini 2.5 Flash - **Styling**: Tailwind CSS 4 + Shadcn/UI -- **Testing**: Vitest 4 (5 files, 22 unit tests) + Playwright 1.59 (18 E2E scenarios) +- **Testing**: Vitest 4 (6 files, 32 unit tests) + Playwright 1.59 (19 E2E scenarios) - **CI**: GitHub Actions (quality + test + e2e jobs) - **Local E2E DB**: Supabase CLI (`supabase start` → ports 54321/54322) @@ -90,7 +89,7 @@ npm run build → ✅ production build succeeds | `src/app/dashboard/` | Admin portal pages | | `src/app/aluno/` | Student portal pages | | `src/lib/actions/` | Server Actions (auth, alunos, treinos, financeiro) | -| `src/lib/auth.ts` | `requireRole()` + `requireAnyRole()` (It5 pending) | +| `src/lib/auth.ts` | `requireRole()` + `requireAnyRole()` (It5 — live) | | `src/services/` | Business logic (XP, streaks) | | `src/ai/flows/` | Genkit AI flows | | `prisma/schema.prisma` | DB schema | From 7761e84bec62dcb69b22cf1904befcb2473aa5fb Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Tue, 21 Apr 2026 22:37:10 -0300 Subject: [PATCH 17/18] test(it5): add requireAnyRole unit tests to restore auth.ts 100% coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI coverage threshold for src/lib/auth.ts requires 100%. Adding requireAnyRole tests mirrors the requireRole test structure: allowed role resolves without redirect, wrong role → /dashboard, no user → /login, DB error → /dashboard, null record (ALUNO) → /dashboard. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/auth.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts index fbb498fa..e350ce7d 100644 --- a/src/lib/auth.test.ts +++ b/src/lib/auth.test.ts @@ -11,7 +11,7 @@ vi.mock('@/utils/supabase/server', () => ({ createClient: vi.fn(), })); -import { requireRole } from './auth'; +import { requireRole, requireAnyRole } from './auth'; import { createClient } from '@/utils/supabase/server'; const mockCreateClient = vi.mocked(createClient); @@ -105,3 +105,55 @@ describe('requireRole', () => { expect(mockRedirect).toHaveBeenCalledWith('/dashboard'); }); }); + +describe('requireAnyRole', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves without redirect when user role is in the allowed list', async () => { + const supabase = buildSupabaseMock({ userId: 'user-1', role: 'INSTRUTOR' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockCreateClient.mockResolvedValue(supabase as any); + + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('redirects to /dashboard when user role is not in the allowed list', async () => { + const supabase = buildSupabaseMock({ userId: 'user-1', role: 'RECEPCIONISTA' }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockCreateClient.mockResolvedValue(supabase as any); + + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + expect(mockRedirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('redirects to /login when no authenticated user', async () => { + const supabase = buildSupabaseMock({ userId: undefined }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockCreateClient.mockResolvedValue(supabase as any); + + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + expect(mockRedirect).toHaveBeenCalledWith('/login'); + }); + + it('redirects to /dashboard (fail-closed) when DB query errors', async () => { + const supabase = buildSupabaseMock({ userId: 'user-1', dbError: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockCreateClient.mockResolvedValue(supabase as any); + + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + expect(mockRedirect).toHaveBeenCalledWith('/dashboard'); + }); + + it('redirects to /dashboard when role is null (ALUNO — no funcionario record)', async () => { + const supabase = buildSupabaseMock({ userId: 'user-1', role: undefined }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase mock does not match full client type + mockCreateClient.mockResolvedValue(supabase as any); + + await requireAnyRole(['INSTRUTOR', 'GERENTE']); + expect(mockRedirect).toHaveBeenCalledWith('/dashboard'); + }); +}); From b9035a384bb65a3e5e458a1d4b88fa47b5ab2458 Mon Sep 17 00:00:00 2001 From: Emiya Kiritsugu Date: Wed, 22 Apr 2026 00:29:04 -0300 Subject: [PATCH 18/18] fix(auth): address CodeRabbit review findings on PR #81 auth.ts: requireRole now delegates to requireAnyRole([role]) eliminating duplicated auth/redirect logic; consistent fail-closed behavior guaranteed by single implementation. treinos.ts: fix two security issues flagged by CodeRabbit: - upsertTreinoAction: ALUNOs (funcData=null) now get alunoId=user.id server-side, preventing cross-user spoofing of alunoId from client - updateTreinoDayAction + deleteTreinoAction: ownership guard now also checks treino.alunoId === user.id, allowing ALUNOs to manage their own treinos (previously blocked because instrutorId is null on ALUNO-created treinos) instrutor-auth-negative.spec.ts: ALUNO redirect test now asserts destination /aluno (middleware redirects non-funcionarios to /aluno) instead of weak negative assertion. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/actions/treinos.ts | 22 +++++++++---- src/lib/auth.ts | 32 +++---------------- .../e2e/specs/instrutor-auth-negative.spec.ts | 8 ++--- 3 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/lib/actions/treinos.ts b/src/lib/actions/treinos.ts index 7eddeb85..a6eb8c75 100644 --- a/src/lib/actions/treinos.ts +++ b/src/lib/actions/treinos.ts @@ -31,7 +31,7 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & if (roleError) return { success: false, error: 'Erro ao verificar permissões' }; - // RECEPCIONISTA is explicitly blocked; ALUNO (not in funcionarios) gets null + // RECEPCIONISTA is explicitly blocked; ALUNO (not in funcionarios) gets null instrutorId if (funcData?.role === 'RECEPCIONISTA') { return { success: false, error: 'Acesso não autorizado' }; } @@ -46,7 +46,9 @@ export async function upsertTreinoAction(treinoData: TreinoBase | (TreinoBase & } // Extraímos os dados validados. 'id' será undefined se for Base. - const { alunoId, objetivo, exercicios, diaSemana } = validatedData; + const { objetivo, exercicios, diaSemana } = validatedData; + // ALUNOs: override alunoId with server-verified user.id to prevent cross-user spoofing + const alunoId = funcData === null ? user.id : validatedData.alunoId; const id = 'id' in validatedData ? (validatedData as TreinoBase & { id: string }).id : undefined; @@ -119,10 +121,14 @@ export async function updateTreinoDayAction(treinoId: string, diaSemana: number const treino = await prisma.treino.findUnique({ where: { id: treinoId }, - select: { instrutorId: true }, + select: { instrutorId: true, alunoId: true }, }); - if (funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id) { + if ( + funcData?.role !== 'GERENTE' && + treino?.instrutorId !== user.id && + treino?.alunoId !== user.id + ) { return { success: false, error: 'Acesso não autorizado' }; } @@ -155,10 +161,14 @@ export async function deleteTreinoAction(treinoId: string) { const treino = await prisma.treino.findUnique({ where: { id: treinoId }, - select: { instrutorId: true }, + select: { instrutorId: true, alunoId: true }, }); - if (funcData?.role !== 'GERENTE' && treino?.instrutorId !== user.id) { + if ( + funcData?.role !== 'GERENTE' && + treino?.instrutorId !== user.id && + treino?.alunoId !== user.id + ) { return { success: false, error: 'Acesso não autorizado' }; } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 66e12804..2627f064 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -27,8 +27,9 @@ export async function requireAnyRole(allowedRoles: Role[]): Promise { .maybeSingle(); if (error) { - Logger.error(`[requireAnyRole] DB error fetching role: ${error.message}`, error); + Logger.error(`[auth] DB error fetching role: ${error.message}`, error); redirect('/dashboard'); + return; } if (!data || !allowedRoles.includes(data.role)) { @@ -38,33 +39,8 @@ export async function requireAnyRole(allowedRoles: Role[]): Promise { /** * Asserts that the currently authenticated user has the required role. - * Redirects to '/login' if no session exists. - * Redirects to '/dashboard' if role doesn't match (fail-closed on DB errors). + * Delegates to requireAnyRole for consistent fail-closed behavior. */ export async function requireRole(allowedRole: Role): Promise { - const supabase = await createClient(); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - redirect('/login'); - return; - } - - const { data, error } = await supabase - .from('funcionarios') - .select('role') - .eq('id', user.id) - .maybeSingle(); - - if (error) { - Logger.error(`[requireRole] DB error fetching role: ${error.message}`, error); - redirect('/dashboard'); - } - - if (!data || data.role !== allowedRole) { - redirect('/dashboard'); - } + return requireAnyRole([allowedRole]); } diff --git a/tests/e2e/specs/instrutor-auth-negative.spec.ts b/tests/e2e/specs/instrutor-auth-negative.spec.ts index d07d3058..8f5ee266 100644 --- a/tests/e2e/specs/instrutor-auth-negative.spec.ts +++ b/tests/e2e/specs/instrutor-auth-negative.spec.ts @@ -13,13 +13,11 @@ test.describe('INSTRUTOR auth — negative scenarios', () => { await expect(page).toHaveURL(/\/dashboard(?!\/treinos)/); }); - test('ALUNO cannot access /dashboard/treinos — redirected away', async ({ page }) => { + test('ALUNO cannot access /dashboard/treinos — redirected to /aluno', async ({ page }) => { await loginAs(page, 'ALUNO'); await page.goto('/dashboard/treinos'); - await page.waitForURL((url) => !url.pathname.startsWith('/dashboard/treinos'), { - timeout: 15_000, - }); - expect(page.url()).not.toContain('/dashboard/treinos'); + await page.waitForURL((url) => url.pathname.startsWith('/aluno'), { timeout: 15_000 }); + await expect(page).toHaveURL(/\/aluno/); }); test.afterEach(async ({ page }) => {