From e12da5385ce465a274563c97e623230c5ffbc1c0 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 8 May 2026 22:16:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=203=20=E2=80=94=20requireA?= =?UTF-8?q?dmin=20middleware=20+=20verify-admin=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User in admin_users-tabelle steht. New schema: - model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id, created_at, added_by). Migration 20260508_admin_users/migration.sql. - ⚠️ SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline triggert. New backend code: - backend/server/db/admin.ts: isAdminUser(userId) → boolean - backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser + isAdminUser-check. Throws 403 wenn nicht admin. - backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns { isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd. Tests (5 cases in tests/admin/verify-admin.test.ts): - isAdminUser DB-layer: row exists/null - requireAdmin: admin → user, non-admin → 403, no token → 401 - Endpoint: admin → success, non-admin → 403 Pending User-Actions nach Push+Deploy: 1. Migration deploy auf staging: ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy 2. Seed-Admin eintragen: INSERT INTO "rebreak"."admin_users" ("user_id", "created_at") VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW()) ON CONFLICT DO NOTHING; 3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole() gegen GET /api/admin/verify-admin aufrufen Co-Authored-By: Claude Opus 4.7 (1M context) --- .../20260508_admin_users/migration.sql | 18 ++ backend/prisma/schema.prisma | 11 ++ backend/server/api/admin/verify-admin.get.ts | 14 ++ backend/server/db/admin.ts | 11 ++ backend/server/utils/auth.ts | 16 ++ backend/tests/admin/verify-admin.test.ts | 157 ++++++++++++++++++ 6 files changed, 227 insertions(+) create mode 100644 backend/prisma/migrations/20260508_admin_users/migration.sql create mode 100644 backend/server/api/admin/verify-admin.get.ts create mode 100644 backend/server/db/admin.ts create mode 100644 backend/tests/admin/verify-admin.test.ts diff --git a/backend/prisma/migrations/20260508_admin_users/migration.sql b/backend/prisma/migrations/20260508_admin_users/migration.sql new file mode 100644 index 0000000..0e879c4 --- /dev/null +++ b/backend/prisma/migrations/20260508_admin_users/migration.sql @@ -0,0 +1,18 @@ +-- Admin-Users Allowlist für Admin-App-Zugang +-- Wird via requireAdmin() middleware geprüft. +-- +-- Seed-User (chahine / backoffice): +-- INSERT INTO "rebreak"."admin_users" ("user_id", "created_at") +-- VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW()) +-- ON CONFLICT DO NOTHING; +-- +-- Deploy: pnpm prisma migrate deploy auf Hetzner-Server +-- NICHT lokal ausführen. + +CREATE TABLE IF NOT EXISTS "rebreak"."admin_users" ( + "user_id" UUID NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "added_by" TEXT, + + CONSTRAINT "admin_users_pkey" PRIMARY KEY ("user_id") +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e06a7c9..79ada6a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -616,6 +616,17 @@ model LyraMemory { @@schema("rebreak") } +/// Admin-Allowlist — nur Einträge hier erhalten Zugang zur Admin-App. +/// Seed: INSERT INTO "rebreak"."admin_users" ("user_id", "created_at") VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW()); +model AdminUser { + userId String @id @map("user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + addedBy String? @map("added_by") + + @@map("admin_users") + @@schema("rebreak") +} + // Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices). // Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird // bei jedem authentifizierten Request via x-device-id Header geprüft. diff --git a/backend/server/api/admin/verify-admin.get.ts b/backend/server/api/admin/verify-admin.get.ts new file mode 100644 index 0000000..9048cf3 --- /dev/null +++ b/backend/server/api/admin/verify-admin.get.ts @@ -0,0 +1,14 @@ +import { requireAdmin } from '../../utils/auth'; + +export default defineEventHandler(async (event) => { + const user = await requireAdmin(event); + + return { + success: true, + data: { + isAdmin: true, + userId: user.id, + email: user.email ?? null, + }, + }; +}); diff --git a/backend/server/db/admin.ts b/backend/server/db/admin.ts new file mode 100644 index 0000000..d32c80e --- /dev/null +++ b/backend/server/db/admin.ts @@ -0,0 +1,11 @@ +import { usePrisma } from "../utils/prisma"; + +/** + * Prüft ob eine User-ID in der admin_users Allowlist steht. + * Gibt true zurück wenn admin, false sonst. + */ +export async function isAdminUser(userId: string): Promise { + const db = usePrisma(); + const row = await db.adminUser.findUnique({ where: { userId } }); + return row !== null; +} diff --git a/backend/server/utils/auth.ts b/backend/server/utils/auth.ts index 96a1cde..461e7d5 100644 --- a/backend/server/utils/auth.ts +++ b/backend/server/utils/auth.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js'; import type { H3Event } from 'h3'; +import { isAdminUser } from '../db/admin'; import { findUserDevice, registerDevice, touchDevice } from '../db/devices'; import { getProfile } from '../db/profile'; import { getPlanLimits } from './plan-features'; @@ -95,4 +96,19 @@ export async function requireUser( } throw err; } +} + +/** + * requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users. + * Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding). + */ +export async function requireAdmin(event: H3Event) { + const user = await requireUser(event, { skipDeviceCheck: true }); + + const admin = await isAdminUser(user.id); + if (!admin) { + throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' }); + } + + return user; } \ No newline at end of file diff --git a/backend/tests/admin/verify-admin.test.ts b/backend/tests/admin/verify-admin.test.ts new file mode 100644 index 0000000..0545b1a --- /dev/null +++ b/backend/tests/admin/verify-admin.test.ts @@ -0,0 +1,157 @@ +/** + * Tests for GET /api/admin/verify-admin + * + * Covers: + * - happy path: User ist admin → isAdmin: true + * - 403: User ist authentifiziert aber NICHT in admin_users + * - 401: User ist nicht eingeloggt + * + * Strategy: + * - isAdminUser DB-layer: tested directly with mocked Prisma + * - requireAdmin util: tested directly with mocked requireUser + isAdminUser + * - endpoint handler: tested with mocked requireAdmin + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// ─── Prisma mock ───────────────────────────────────────────────────────────── + +const prismaMock = vi.hoisted(() => ({ + adminUser: { + findUnique: vi.fn(), + }, +})); + +vi.mock("../../server/utils/prisma", () => ({ + usePrisma: () => prismaMock, +})); + +// ─── requireUser mock (used by requireAdmin tests) ─────────────────────────── + +const requireUserMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../server/utils/auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireUser: requireUserMock, + }; +}); + +import { isAdminUser } from "../../server/db/admin"; +import { requireAdmin } from "../../server/utils/auth"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ─── isAdminUser DB-layer ──────────────────────────────────────────────────── + +describe("isAdminUser — user IS in admin_users", () => { + it("returns true when row exists", async () => { + prismaMock.adminUser.findUnique.mockResolvedValueOnce({ + userId: "128df360-2008-4d6f-8aa1-bdb41ec1362f", + createdAt: new Date(), + addedBy: null, + }); + + const result = await isAdminUser("128df360-2008-4d6f-8aa1-bdb41ec1362f"); + + expect(result).toBe(true); + expect(prismaMock.adminUser.findUnique).toHaveBeenCalledWith({ + where: { userId: "128df360-2008-4d6f-8aa1-bdb41ec1362f" }, + }); + }); +}); + +describe("isAdminUser — user NOT in admin_users", () => { + it("returns false when row is null", async () => { + prismaMock.adminUser.findUnique.mockResolvedValueOnce(null); + + const result = await isAdminUser("some-random-user-id"); + + expect(result).toBe(false); + }); +}); + +// ─── requireAdmin util ─────────────────────────────────────────────────────── + +describe("requireAdmin — happy path (admin user)", () => { + it("returns user object when authenticated and in admin_users", async () => { + const fakeUser = { + id: "128df360-2008-4d6f-8aa1-bdb41ec1362f", + email: "chahinebrini@gmail.com", + }; + requireUserMock.mockResolvedValueOnce(fakeUser); + prismaMock.adminUser.findUnique.mockResolvedValueOnce({ + userId: fakeUser.id, + createdAt: new Date(), + addedBy: null, + }); + + const result = await requireAdmin({} as Parameters[0]); + + expect(result).toMatchObject({ id: fakeUser.id, email: fakeUser.email }); + }); +}); + +describe("requireAdmin — 403 (non-admin)", () => { + it("throws 403 when user is authenticated but not in admin_users", async () => { + requireUserMock.mockResolvedValueOnce({ + id: "regular-user-id", + email: "user@example.com", + }); + prismaMock.adminUser.findUnique.mockResolvedValueOnce(null); + + await expect( + requireAdmin({} as Parameters[0]), + ).rejects.toMatchObject({ statusCode: 403 }); + }); +}); + +describe("requireAdmin — 401 (not logged in)", () => { + it("propagates 401 from requireUser when token missing", async () => { + const authError = Object.assign(new Error("Nicht eingeloggt"), { + statusCode: 401, + }); + requireUserMock.mockRejectedValueOnce(authError); + + await expect( + requireAdmin({} as Parameters[0]), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +// ─── verify-admin endpoint handler ─────────────────────────────────────────── + +describe("verify-admin endpoint — returns isAdmin: true for admin", () => { + it("returns { success: true, data: { isAdmin: true, userId, email } }", async () => { + const fakeUser = { + id: "128df360-2008-4d6f-8aa1-bdb41ec1362f", + email: "chahinebrini@gmail.com", + }; + // requireAdmin is backed by requireUser mock (via spread in vi.mock above) + requireUserMock.mockResolvedValueOnce(fakeUser); + prismaMock.adminUser.findUnique.mockResolvedValueOnce({ + userId: fakeUser.id, + createdAt: new Date(), + addedBy: null, + }); + + const mod = await import("../../server/api/admin/verify-admin.get"); + const handler = + typeof mod.default === "function" + ? mod.default + : (mod.default as { handler?: unknown }).handler; + + const result = await (handler as (e: unknown) => Promise)({}); + + expect(result).toEqual({ + success: true, + data: { + isAdmin: true, + userId: fakeUser.id, + email: fakeUser.email, + }, + }); + }); +});