From fc3e0ae21bbbe7c20f73193058286deae4c0c720 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 02:02:00 +0300 Subject: [PATCH] =?UTF-8?q?test:=20admin=20seed=20ve=20kimlik=20do=C4=9Fru?= =?UTF-8?q?lama=20entegrasyon=20testleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- package.json | 9 +- scripts/seed.ts | 70 +++++++++++-- src/lib/auth.int.test.ts | 191 ++++++++++++++++++++++++++++++++++ src/test/server-only-stub.ts | 2 + vitest.config.mts | 9 +- vitest.integration.config.mts | 37 +++++++ 6 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 src/lib/auth.int.test.ts create mode 100644 src/test/server-only-stub.ts create mode 100644 vitest.integration.config.mts diff --git a/package.json b/package.json index a2686e4..ad71ec8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -18,9 +18,10 @@ "format:check": "prettier --check .", "db:up": "docker compose up -d --wait", "db:down": "docker compose down", - "seed": "tsx --env-file=.env.local scripts/seed.ts", - "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", - "icons": "tsx scripts/generate-icons.ts" + "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts", + "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", + "icons": "tsx scripts/generate-icons.ts", + "test:integration": "vitest run --config vitest.integration.config.mts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", diff --git a/scripts/seed.ts b/scripts/seed.ts index 9f80154..38aa3e8 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,10 +1,68 @@ /** - * Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi). - * Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset + * Seed betiği: npm run seed | Sıfırlayıp doldurma: npm run seed:reset + * + * Faz 3: yalnızca ilk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD, kullanıcı adı "admin"). + * Faz 4: örnek kullanıcılar, hane ve aile üyeleri eklenecek. + * + * İdempotent: kullanıcı e-postasına göre kontrol eder, tekrar çalıştırınca kopya oluşturmaz. + * Kullanıcılar Better Auth API'siyle oluşturulur (şifre doğru hashlenir; koleksiyona elle yazılmaz). + * `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır. */ -if (process.env.NODE_ENV === "production" && process.argv.includes("--reset")) { - console.error("seed:reset production ortamında çalıştırılamaz."); - process.exit(1); +import { getAuth } from "@/lib/auth"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { env } from "@/lib/env"; + +const reset = process.argv.includes("--reset"); + +async function main() { + const e = env(); + if (reset && e.NODE_ENV === "production") { + throw new Error("seed:reset production ortamında çalıştırılamaz."); + } + if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) { + throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı (.env.local)."); + } + + const db = getDb(); + if (reset) { + for (const name of ["user", "account", "session", "verification", "rateLimit"]) { + await db.collection(name).deleteMany({}); + } + console.log("• Kimlik doğrulama koleksiyonları temizlendi."); + } + + const email = e.SEED_ADMIN_EMAIL.toLowerCase(); + const existing = await db.collection("user").findOne({ email }); + if (existing) { + console.log(`• Admin zaten var: ${email}`); + } else { + await getAuth().api.createUser({ + body: { + email, + password: e.SEED_ADMIN_PASSWORD, + name: "Yönetici", + role: "admin", + data: { username: "admin" }, + }, + }); + console.log(`✓ Admin oluşturuldu: ${email} (kullanıcı adı: admin)`); + } + + if (e.NODE_ENV !== "production") { + console.table([ + { + rol: "admin", + kullaniciAdi: "admin", + eposta: email, + sifre: "SEED_ADMIN_PASSWORD (.env.local)", + }, + ]); + } } -console.log("Seed henüz uygulanmadı (Faz 4)."); +main() + .catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }) + .finally(() => getMongoClient().close()); diff --git a/src/lib/auth.int.test.ts b/src/lib/auth.int.test.ts new file mode 100644 index 0000000..e3a7ba0 --- /dev/null +++ b/src/lib/auth.int.test.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getAuth } from "@/lib/auth"; +import { authErrorCode } from "@/lib/auth-errors"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { placeholderEmail } from "@/shared/account-email"; + +/** + * Better Auth yapılandırmasının güvenlik sözleşmesi (gerçek MongoDB, butcem_test veritabanı). + */ + +const auth = getAuth(); +const PASSWORD = "Gizli-Sifre-123"; + +/** Giriş yapıp oturum cookie'sini içeren istek başlıklarını döner. */ +async function signIn(username: string, password = PASSWORD): Promise { + const { headers } = await auth.api.signInUsername({ + body: { username, password }, + returnHeaders: true, + }); + const cookie = headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return new Headers({ cookie }); +} + +async function expectAuthError(promise: Promise, code: string) { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error, `beklenen hata: ${code}`).not.toBeNull(); + expect(authErrorCode(error)).toBe(code); +} + +beforeAll(async () => { + const db = getDb(); + expect(db.databaseName).toBe("butcem_test"); + await db.dropDatabase(); + await auth.api.createUser({ + body: { + email: "admin@test.local", + password: PASSWORD, + name: "Admin", + role: "admin", + data: { username: "admin" }, + }, + }); + await auth.api.createUser({ + body: { + email: placeholderEmail("emre"), + password: PASSWORD, + name: "Emre", + role: "user", + data: { username: "Emre" }, + }, + }); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await getMongoClient().close(); +}); + +describe("kayıt kapalı", () => { + it("sign-up/email uç noktası reddedilir ve kullanıcı oluşmaz", async () => { + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://localhost:3000" }, + body: JSON.stringify({ email: "yeni@test.local", password: PASSWORD, name: "Yeni" }), + }), + ); + expect(res.ok).toBe(false); + expect(await getDb().collection("user").countDocuments({ email: "yeni@test.local" })).toBe(0); + }); +}); + +describe("giriş", () => { + it("kullanıcı adı küçük harfe normalize edilir ve kullanıcı adıyla giriş yapılır", async () => { + const stored = await getDb() + .collection("user") + .findOne({ email: placeholderEmail("emre") }); + expect(stored?.username).toBe("emre"); + const headers = await signIn("EMRE"); + const session = await auth.api.getSession({ headers }); + expect(session?.user.name).toBe("Emre"); + expect(session?.user.role).toBe("user"); + }); + + it("HTTP üzerinden büyük harfli (I içeren) kullanıcı adıyla giriş yapılır", async () => { + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/username", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + "x-forwarded-for": "198.51.100.1", + }, + // "I" içeren büyük harfli giriş (tr-TR'de "ADMIN" → "admın" tuzağı) + body: JSON.stringify({ username: "ADMIN", password: PASSWORD }), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().some((c) => c.includes("session_token"))).toBe(true); + }); + + it("e-posta ile giriş yapılır", async () => { + const result = await auth.api.signInEmail({ + body: { email: "admin@test.local", password: PASSWORD }, + }); + expect(result.user.email).toBe("admin@test.local"); + }); + + it("yanlış şifre reddedilir", async () => { + await expectAuthError( + auth.api.signInUsername({ body: { username: "emre", password: "yanlis-sifre" } }), + "INVALID_USERNAME_OR_PASSWORD", + ); + }); + + it("aynı kullanıcı adı ikinci kez kullanılamaz", async () => { + await expectAuthError( + auth.api.createUser({ + body: { + email: "baska@test.local", + password: PASSWORD, + name: "Kopya", + data: { username: "emre" }, + }, + }), + "USERNAME_IS_ALREADY_TAKEN", + ); + }); +}); + +describe("admin yetkileri", () => { + it("admin olmayan kullanıcı admin API'sini çağıramaz", async () => { + const headers = await signIn("emre"); + const res = await auth.api + .listUsers({ headers, query: { limit: 10 } }) + .then(() => "ok") + .catch((e: unknown) => authErrorCode(e) ?? "error"); + expect(res).not.toBe("ok"); + }); + + it("askıya alınan kullanıcı giriş yapamaz, askı kaldırılınca yapabilir", async () => { + const adminHeaders = await signIn("admin"); + const emre = await getDb().collection("user").findOne({ username: "emre" }); + const userId = String(emre?._id); + + await auth.api.banUser({ headers: adminHeaders, body: { userId, banReason: "test" } }); + await expectAuthError( + auth.api.signInUsername({ body: { username: "emre", password: PASSWORD } }), + "BANNED_USER", + ); + + await auth.api.unbanUser({ headers: adminHeaders, body: { userId } }); + await expect(signIn("emre")).resolves.toBeInstanceOf(Headers); + }); + + it("admin kendini askıya alamaz", async () => { + const adminHeaders = await signIn("admin"); + const self = await auth.api.getSession({ headers: adminHeaders }); + await expectAuthError( + auth.api.banUser({ headers: adminHeaders, body: { userId: self!.user.id } }), + "YOU_CANNOT_BAN_YOURSELF", + ); + }); +}); + +describe("rate limit", () => { + it("giriş denemeleri dakikada 5 ile sınırlıdır", async () => { + const attempt = () => + auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/username", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + "x-forwarded-for": "203.0.113.7", + }, + body: JSON.stringify({ username: "emre", password: "yanlis-sifre" }), + }), + ); + const statuses: number[] = []; + for (let i = 0; i < 6; i++) statuses.push((await attempt()).status); + expect(statuses.slice(0, 5).every((s) => s !== 429)).toBe(true); + expect(statuses[5]).toBe(429); + }); +}); diff --git a/src/test/server-only-stub.ts b/src/test/server-only-stub.ts new file mode 100644 index 0000000..b159a7a --- /dev/null +++ b/src/test/server-only-stub.ts @@ -0,0 +1,2 @@ +// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır). +export {}; diff --git a/vitest.config.mts b/vitest.config.mts index c50fe37..5f08968 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,12 +3,15 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { - alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)), + }, }, test: { environment: "node", include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], - // "server-only" paketi test ortamında hata fırlatmasın. - server: { deps: { inline: ["server-only"] } }, + // Veritabanı gerektiren testler ayrı: npm run test:integration + exclude: ["**/*.int.test.ts", "node_modules/**"], }, }); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts new file mode 100644 index 0000000..6b388c5 --- /dev/null +++ b/vitest.integration.config.mts @@ -0,0 +1,37 @@ +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; + +/** + * Entegrasyon testleri: gerçek MongoDB (npm run db:up) üzerinde, ayrı "butcem_test" veritabanı. + * Çalıştırma: npm run test:integration + */ +const fileEnv = loadEnv("test", process.cwd(), ""); +const local = { ...loadEnv("development", process.cwd(), ""), ...fileEnv }; +const baseUri = + process.env.MONGODB_URI_TEST ?? + ( + local.MONGODB_URI ?? "mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true" + ).replace(/\/([^/?]+)(\?|$)/, "/butcem_test$2"); + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["src/**/*.int.test.ts"], + fileParallelism: false, + testTimeout: 20_000, + hookTimeout: 30_000, + env: { + NODE_ENV: "test", + MONGODB_URI: baseUri, + BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123", + BETTER_AUTH_URL: "http://localhost:3000", + }, + }, +});