test: admin seed ve kimlik doğrulama entegrasyon testleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+5
-4
@@ -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
@@ -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).");
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır).
|
||||||
|
export {};
|
||||||
+6
-3
@@ -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/**"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user