feat: remove dev_session, add Drizzle schema + RLS + real auth
BREAKING: dev_session cookie bypass removed. Admin access now requires a real Auth.js v5 session (Google OAuth in production). Provision users by inserting into users + tenant_users tables. New in this commit: - db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants, users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons, products, product_images, stops, customers, orders, order_items, brand_settings, email_templates, campaigns, files, audit_log) - db/schema/: Drizzle TypeScript mirror of every table - db/client.ts: withTenant() / withPlatformAdmin() query wrappers that set Postgres GUCs (app.current_tenant_id, app.platform_admin) for RLS enforcement. Never query a tenant-scoped table without one. - db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River Direct), brand_settings, sample products/stops/customers - scripts/migrate.js: applies migrations in lexical order with tracking - scripts/db-reset.js: drops + recreates DB, runs migrate + seed - DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is enforced even for the app user. DATABASE_ADMIN_URL for migrations. - src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session, looks up user + tenant in Postgres. brand_id kept as alias for backward compat. - src/middleware.ts: Auth.js-only route protection, dev_session gone - src/app/login/LoginClient.tsx: Google OAuth only, no demo mode - src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction replaces supabase signout - @/db/* path aliases in tsconfig.json + vitest.config.ts - drizzle.config.ts added - db/auth_schema.sql removed (was a stub; replaced by real schema) - src/app/api/dev-login/route.ts deleted - tests: updated to remove dev_session coverage
This commit is contained in:
+9
-71
@@ -11,9 +11,8 @@ test.describe("Auth.js v5 endpoints", () => {
|
||||
const res = await ctx.get("/api/auth/providers");
|
||||
expect(res.status()).toBe(200);
|
||||
const providers = (await res.json()) as Record<string, unknown>;
|
||||
// The Credentials provider is always present. The Google provider is
|
||||
// only present when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
|
||||
expect(providers).toHaveProperty("supabase-password");
|
||||
// The Google provider is only present when AUTH_GOOGLE_ID +
|
||||
// AUTH_GOOGLE_SECRET are set.
|
||||
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
|
||||
expect(providers).toHaveProperty("google");
|
||||
}
|
||||
@@ -35,7 +34,6 @@ test.describe("Auth.js v5 endpoints", () => {
|
||||
const res = await ctx.get("/api/auth/session");
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
// Auth.js returns `null` (not `{}`) when no session is active.
|
||||
expect(body).toBeNull();
|
||||
await ctx.dispose();
|
||||
});
|
||||
@@ -48,76 +46,16 @@ test.describe("Middleware — protected route gating", () => {
|
||||
test("unauthed /login renders the login form (200)", async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}/login`);
|
||||
expect(res?.status()).toBe(200);
|
||||
// The login form has an aria-label of "Sign in form"
|
||||
await expect(page.getByRole("form", { name: /sign in form/i })).toBeVisible();
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("dev_session=platform_admin lets /admin render (200)", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
const res = await page.goto(`${BASE}/admin`);
|
||||
expect(res?.status()).toBe(200);
|
||||
await expect(page).toHaveURL(/\/admin/);
|
||||
// The admin layout renders a sidebar (aside element)
|
||||
await expect(page.locator("aside").first()).toBeVisible({ timeout: 5_000 });
|
||||
test("unauthed /admin redirects to /login", async ({ page }) => {
|
||||
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).toMatch(/\/login/);
|
||||
});
|
||||
|
||||
test("authed user hitting /login is redirected to /admin", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
await page.goto(`${BASE}/login`);
|
||||
// Middleware redirects authed users from /login to /admin
|
||||
await page.waitForURL(/\/admin/, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Admin pages — load cleanly with dev_session
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Admin pages — load with dev_session", () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const path of ["/admin", "/admin/products", "/admin/orders", "/admin/settings"]) {
|
||||
test(`${path} renders without crashing`, async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}${path}`);
|
||||
expect(res?.status()).toBe(200);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
// The admin layout renders the sidebar; this confirms the auth
|
||||
// gate didn't bounce us back to /login.
|
||||
await expect(page.locator("aside").first()).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Logout flow
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Logout", () => {
|
||||
test("/logout clears the dev_session cookie and redirects to /login", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "dev_session", value: "platform_admin", domain: "localhost", path: "/" },
|
||||
]);
|
||||
|
||||
// /logout is a server component that calls signOut and redirects.
|
||||
await page.goto(`${BASE}/logout`);
|
||||
|
||||
// The Auth.js signOut flow sets a callback URL in the URL, or the
|
||||
// custom /logout page redirects to /login directly. Either way we
|
||||
// should land on /login (or near it).
|
||||
await page.waitForURL(/\/login/, { timeout: 8_000 });
|
||||
|
||||
// The dev_session cookie should be cleared (or Auth.js session
|
||||
// cookie cleared). At minimum, navigating to /admin again should
|
||||
// require re-auth — we can verify by hitting /admin and checking
|
||||
// that we don't have a 200 with the sidebar (the dev session may
|
||||
// be re-issued by the demo flow middleware, so we don't assert
|
||||
// strict denial here; just that logout *did* go to /login).
|
||||
test("unauthed /wholesale redirects to /login", async ({ page }) => {
|
||||
await page.goto(`${BASE}/wholesale`, { waitUntil: "domcontentloaded" });
|
||||
expect(page.url()).toMatch(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user