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:
+23
-104
@@ -3,115 +3,34 @@ import { test, expect } from "@playwright/test";
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Login form renders the right affordances
|
||||
// Login page — Google OAuth is the only login path
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Login form rendering", () => {
|
||||
test("renders Google button, email + password fields, and Sign in button", async ({ page }) => {
|
||||
test.describe("Login page", () => {
|
||||
test("renders the Google sign-in button", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
|
||||
// The Google button is the primary CTA at the top of the form.
|
||||
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
|
||||
|
||||
// Email + password inputs
|
||||
await expect(page.locator("#email")).toBeVisible();
|
||||
await expect(page.locator("#password")).toBeVisible();
|
||||
|
||||
// The "Sign in" submit button is on the email/password form (not on the
|
||||
// Google one). Use the form's aria-label to scope the lookup.
|
||||
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||
await expect(signInForm.getByRole("button", { name: /^sign in$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("missing email surfaces browser required validation", async ({ page }) => {
|
||||
test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
|
||||
// The page either shows the Google button (when AUTH_GOOGLE_ID/SECRET
|
||||
// are set) or a setup message (when they aren't). We accept both as
|
||||
// correct renderings.
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.locator("#password").fill("something");
|
||||
// Don't fill email
|
||||
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||
// The email input has `required` — the browser should block submission
|
||||
// and the email field remains focused. We don't assert on URL change.
|
||||
await expect(page.locator("#email")).toHaveAttribute("required", "");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Demo mode (?demo=1) — the path that works without a Supabase backend
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Demo mode (?demo=1)", () => {
|
||||
test("Platform Admin button sets dev_session and lands on /admin", async ({ page, context }) => {
|
||||
await page.goto(`${BASE}/login?demo=1`);
|
||||
await page.getByRole("button", { name: /platform admin/i }).click();
|
||||
|
||||
// The click sets the cookie client-side and navigates. We should land
|
||||
// on the admin dashboard.
|
||||
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("platform_admin");
|
||||
});
|
||||
|
||||
test("Brand Admin button sets dev_session=brand_admin", async ({ page, context }) => {
|
||||
await page.goto(`${BASE}/login?demo=1`);
|
||||
await page.getByRole("button", { name: /brand admin/i }).click();
|
||||
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("brand_admin");
|
||||
});
|
||||
|
||||
test("Store Employee button sets dev_session=store_employee", async ({ page, context }) => {
|
||||
await page.goto(`${BASE}/login?demo=1`);
|
||||
await page.getByRole("button", { name: /store employee/i }).click();
|
||||
await page.waitForURL(/\/admin/, { timeout: 10_000 });
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("store_employee");
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Credentials sign-in (skipped if env vars aren't set — needs a real
|
||||
// Supabase auth user to actually succeed)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
test.describe("Credentials sign-in", () => {
|
||||
test.skip(
|
||||
!process.env.TEST_ADMIN_EMAIL || !process.env.TEST_ADMIN_PASSWORD,
|
||||
"Set TEST_ADMIN_EMAIL and TEST_ADMIN_PASSWORD to run the credentials flow against a real Supabase backend.",
|
||||
);
|
||||
|
||||
test("valid credentials redirect to /admin", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
|
||||
|
||||
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||
|
||||
await page.waitForURL(/\/admin/, { timeout: 15_000 });
|
||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||
});
|
||||
|
||||
test("wrong password shows an error and stays on /login", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.locator("#password").fill("definitely-wrong-password");
|
||||
|
||||
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||
|
||||
// The error banner uses role="alert"
|
||||
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 8_000 });
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test("session persists across reload", async ({ page }) => {
|
||||
await page.goto(`${BASE}/login`);
|
||||
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
|
||||
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
|
||||
|
||||
const signInForm = page.getByRole("form", { name: /sign in form/i });
|
||||
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
|
||||
await page.waitForURL(/\/admin/, { timeout: 15_000 });
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/admin/);
|
||||
await expect(page.locator("body")).not.toContainText("Access Denied");
|
||||
const hasGoogle = await page
|
||||
.getByRole("button", { name: /continue with google/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasSetup = await page
|
||||
.getByText(/Google sign-in is not configured/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasGoogle || hasSetup).toBe(true);
|
||||
});
|
||||
|
||||
test("/admin redirects to /login when not authenticated", async ({ page }) => {
|
||||
const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
|
||||
// After redirect, the URL should be /login (with ?redirect=/admin).
|
||||
expect(page.url()).toMatch(/\/login(\?|$)/);
|
||||
expect(res?.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user