test: admin seed ve kimlik doğrulama entegrasyon testleri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:02:00 +03:00
parent 97e7ebe9aa
commit fc3e0ae21b
6 changed files with 305 additions and 13 deletions
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -18,9 +18,10 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"db:up": "docker compose up -d --wait", "db:up": "docker compose up -d --wait",
"db:down": "docker compose down", "db:down": "docker compose down",
"seed": "tsx --env-file=.env.local scripts/seed.ts", "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
"seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
"icons": "tsx scripts/generate-icons.ts" "icons": "tsx scripts/generate-icons.ts",
"test:integration": "vitest run --config vitest.integration.config.mts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
+64 -6
View File
@@ -1,10 +1,68 @@
/** /**
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi). * Seed betiği: npm run seed | Sıfırlayıp doldurma: npm run seed:reset
* Çalıştırma: 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")) { import { getAuth } from "@/lib/auth";
console.error("seed:reset production ortamında çalıştırılamaz."); import { getDb, getMongoClient } from "@/lib/db/mongo";
process.exit(1); 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).");
} }
console.log("Seed henüz uygulanmadı (Faz 4)."); 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)",
},
]);
}
}
main()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(() => getMongoClient().close());
+191
View File
@@ -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<Headers> {
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<unknown>, 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);
});
});
+2
View File
@@ -0,0 +1,2 @@
// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır).
export {};
+6 -3
View File
@@ -3,12 +3,15 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
resolve: { 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: { test: {
environment: "node", environment: "node",
include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], include: ["src/**/*.test.ts", "scripts/**/*.test.ts"],
// "server-only" paketi test ortamında hata fırlatmasın. // Veritabanı gerektiren testler ayrı: npm run test:integration
server: { deps: { inline: ["server-only"] } }, exclude: ["**/*.int.test.ts", "node_modules/**"],
}, },
}); });
+37
View File
@@ -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",
},
},
});