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",
|
||||
"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",
|
||||
|
||||
+64
-6
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
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/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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