Merge main into feat/gro-395-demo-assets

Resolve conflict in settings.ts: keep S3 logo migration imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
groombook-engineer[bot]
2026-04-02 13:32:14 +00:00
5 changed files with 86 additions and 8 deletions
+2 -2
View File
@@ -395,11 +395,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}" git push -u origin "chore/update-image-tags-${TAG}"
# Create PR with auto-merge # Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \ PR_URL=$(gh pr create \
--repo groombook/infra \ --repo groombook/infra \
--base main \ --base main \
--head "chore/update-image-tags-${TAG}" \ --head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \ --title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge") --body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --auto --merge gh pr merge "$PR_URL" --merge
+74 -1
View File
@@ -25,6 +25,7 @@ const RECEPTIONIST: StaffRow = {
oidcSub: "oidc-receptionist-sub", oidcSub: "oidc-receptionist-sub",
userId: "ba-user-receptionist", userId: "ba-user-receptionist",
role: "receptionist", role: "receptionist",
isSuperUser: false,
name: "Receptionist Rita", name: "Receptionist Rita",
email: "receptionist@example.com", email: "receptionist@example.com",
}; };
@@ -35,6 +36,7 @@ const GROOMER: StaffRow = {
oidcSub: "oidc-groomer-sub", oidcSub: "oidc-groomer-sub",
userId: "ba-user-groomer", userId: "ba-user-groomer",
role: "groomer", role: "groomer",
isSuperUser: false,
name: "Groomer Gary", name: "Groomer Gary",
email: "groomer@example.com", email: "groomer@example.com",
}; };
@@ -122,7 +124,7 @@ function buildWithStaff(
// ─── Import middleware ──────────────────────────────────────────────────────── // ─── Import middleware ────────────────────────────────────────────────────────
const { resolveStaffMiddleware, requireRole } = await import( const { resolveStaffMiddleware, requireRole, requireSuperUser } = await import(
"../middleware/rbac.js" "../middleware/rbac.js"
); );
@@ -253,3 +255,74 @@ describe("requireRole", () => {
expect(contentType).toContain("application/json"); expect(contentType).toContain("application/json");
}); });
}); });
// ─── requireSuperUser tests ─────────────────────────────────────────────────
describe("requireSuperUser", () => {
it("allows access when staff is a super user", async () => {
const app = buildWithStaff(MANAGER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("allows access when manager is also a super user", async () => {
// MANAGER has isSuperUser: true
const app = buildWithStaff(MANAGER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(200);
});
it("returns 403 for a non-super-user receptionist", async () => {
// RECEPTIONIST has isSuperUser: false
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("returns 403 for a non-super-user groomer", async () => {
// GROOMER has isSuperUser: false
const app = buildWithStaff(GROOMER, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
});
it("returns 403 when staff record is not resolved", async () => {
// Manually remove staff from context to simulate unresolved staff
const testApp = new Hono<AppEnv>();
testApp.use("*", async (c, next) => {
c.set("jwtPayload", { sub: "test-sub" });
// Do NOT set staff - simulate unresolved staff
await next();
});
testApp.use("*", requireSuperUser());
testApp.get("/test", (c) => c.json({ ok: true }));
const res = await testApp.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/staff record not resolved/i);
});
it("receptionist cannot grant super user status on staff PATCH", async () => {
// This tests the inline guard in staff.ts handler, not the middleware itself,
// but we test requireSuperUser to verify the middleware correctly blocks
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isSuperUser: true }),
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/super user privileges required/i);
});
it("returns 403 with JSON body for super user violation", async () => {
const app = buildWithStaff(RECEPTIONIST, requireSuperUser());
const res = await app.request("/test");
expect(res.status).toBe(403);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("application/json");
});
});
+3 -3
View File
@@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
if (!manager) { if (!manager) {
return c.json({ error: "Forbidden: no staff records found" }, 403); return c.json({ error: "Forbidden: no staff records found" }, 403);
} }
c.set("staff", { ...manager, isSuperUser: true }); c.set("staff", { ...manager, isSuperUser: manager.isSuperUser ?? false });
await next(); await next();
return; return;
} }
@@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff) .from(staff)
.where(eq(staff.userId, devUserId)); .where(eq(staff.userId, devUserId));
if (row) { if (row) {
c.set("staff", { ...row, isSuperUser: true }); c.set("staff", { ...row, isSuperUser: row.isSuperUser ?? false });
await next(); await next();
return; return;
} }
@@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
403 403
); );
} }
c.set("staff", { ...fallbackRow, isSuperUser: true }); c.set("staff", { ...fallbackRow, isSuperUser: fallbackRow.isSuperUser ?? false });
await next(); await next();
return; return;
} }
+2
View File
@@ -3,6 +3,7 @@ import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3"; import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "@groombook/db"; import { eq, getDb, businessSettings } from "@groombook/db";
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js"; import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
export const settingsRouter = new Hono(); export const settingsRouter = new Hono();
@@ -29,6 +30,7 @@ const updateSettingsSchema = z.object({
// PATCH /api/admin/settings — update business settings // PATCH /api/admin/settings — update business settings
settingsRouter.patch( settingsRouter.patch(
"/", "/",
requireSuperUser(),
zValidator("json", updateSettingsSchema), zValidator("json", updateSettingsSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
+5 -2
View File
@@ -262,6 +262,9 @@ export function App() {
return <Navigate to="/setup" replace />; return <Navigate to="/setup" replace />;
} }
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
return ( return (
<BrandingProvider> <BrandingProvider>
{location.pathname.startsWith("/admin") ? ( {location.pathname.startsWith("/admin") ? (
@@ -271,12 +274,12 @@ export function App() {
</Routes> </Routes>
{authDisabled && <DevSessionIndicator />} {authDisabled && <DevSessionIndicator />}
</> </>
) : ( ) : showCustomerPortal ? (
<> <>
<CustomerPortal /> <CustomerPortal />
{authDisabled && <DevSessionIndicator />} {authDisabled && <DevSessionIndicator />}
</> </>
)} ) : null}
</BrandingProvider> </BrandingProvider>
); );
} }