Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Farhood 54bbb45f5e docs(UAT): add rate limit test cases for PR #11
Added TC-API-15.1 through TC-API-15.6 covering sign-in/sign-up
rate limit boundaries for email and social providers.

Refs: GRO-1244
Updated UAT_PLAYBOOK.md §4.15

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 13:08:40 +00:00
6 changed files with 42 additions and 140 deletions
+4 -4
View File
@@ -202,20 +202,20 @@ jobs:
echo "Updating dev overlay image tags to: $TAG"
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/overlays/dev/kustomization.yaml"
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/reset")).newTag = env(TAG)' "$DEV_KUST"
MIGRATE_JOB="apps/base/migrate-job.yaml"
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
fi
SEED_JOB="apps/base/seed-job.yaml"
SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -237,7 +237,7 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
git add apps/overlays/dev/ apps/base/migrate-job.yaml apps/base/seed-job.yaml
git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
+2 -2
View File
@@ -3,7 +3,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml ./
COPY apps/api/package.json apps/api/
RUN pnpm install --frozen-lockfile
@@ -17,7 +17,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml ./
COPY --from=builder /app/apps/api/package.json apps/api/
COPY --from=builder /app/apps/api/dist apps/api/dist
RUN pnpm install --frozen-lockfile --prod
+11 -1
View File
@@ -28,7 +28,6 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-1.1 | Login via OIDC | POST to OIDC provider callback, verify JWT token issued | 200 OK, JWT returned with valid claims |
| TC-API-1.2 | Session persistence | Make authenticated request, verify session token valid | 200 OK, request succeeds |
| TC-API-1.3 | Logout | Call logout endpoint, verify token invalidated | 200 OK, subsequent requests return 401 |
| TC-API-1.4 | Auto-provision on first OIDC login | First login as a Better-Auth user with no existing staff record | 200 OK, access granted; groomer staff record auto-created with name/email from user table |
### 4.2 Client Management
@@ -178,6 +177,17 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
| TC-API-14.4 | Update group notes | PATCH /api/appointment-groups/{id} with notes | 200 OK, notes updated |
| TC-API-14.5 | Cancel group | DELETE /api/appointment-groups/{id} | 200 OK, all appointments cancelled |
### 4.15 Rate Limiting
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-API-15.1 | Sign-in email: within limit | POST /api/auth/sign-in/email 10 times within 60s | All requests return 200 OK, no 429 |
| TC-API-15.2 | Sign-in social: within limit | POST /api/auth/sign-in/social 10 times within 60s | All requests return 200 OK, no 429 |
| TC-API-15.3 | Sign-up email: within limit | POST /api/auth/sign-up/email 5 times within 60s | All requests return 200 OK, no 429 |
| TC-API-15.4 | Sign-in email: beyond limit | POST /api/auth/sign-in/email 11 times within 60s | 11th request returns 429 Too Many Requests |
| TC-API-15.5 | Sign-in social: beyond limit | POST /api/auth/sign-in/social 11 times within 60s | 11th request returns 429 Too Many Requests |
| TC-API-15.6 | Sign-up email: beyond limit | POST /api/auth/sign-up/email 6 times within 60s | 6th request returns 429 Too Many Requests |
## Pass/Fail Criteria
**Pass:**
+23 -105
View File
@@ -45,76 +45,40 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
let userLookupResult: { id: string; name: string | null; email: string | null } | null = null;
let _insertedStaff: StaffRow | null = null;
vi.mock("../db", () => {
const makeTableProxy = (name: string) =>
new Proxy(
{ _name: name },
{
get(target, prop) {
if (prop === "_name") return name;
if (prop === "$inferSelect") return {};
return { table: name, column: prop };
},
}
);
const staff = makeTableProxy("staff");
const user = makeTableProxy("user");
const buildQuery = (result: unknown, fallback: unknown) => ({
[Symbol.iterator]: function* () {
if (result) yield result;
},
limit: (_n: number) => {
const item = result ?? fallback;
return {
[Symbol.iterator]: function* () { if (item) yield item; },
0: item,
length: item ? 1 : 0,
};
},
});
const staff = new Proxy(
{ _name: "staff" },
{
get(target, prop) {
if (prop === "_name") return "staff";
if (prop === "$inferSelect") return {};
return { table: "staff", column: prop };
},
}
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => ({
where: () => buildQuery(
table === staff ? staffLookupResult : userLookupResult,
table === staff ? managerFallbackResult : null
),
}),
}),
insert: (_table: unknown) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
const newStaff: StaffRow = {
id: "new-staff-id",
oidcSub: null,
userId: vals.userId as string,
role: vals.role as StaffRow["role"],
isSuperUser: false,
name: vals.name as string,
email: vals.email as string,
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
_insertedStaff = newStaff;
return [newStaff];
},
from: () => ({
where: () => ({
limit: () => {
// dev mode fallback to first manager
return managerFallbackResult ? [managerFallbackResult] : [];
},
[Symbol.iterator]: function* () {
if (staffLookupResult) yield staffLookupResult;
},
0: staffLookupResult,
length: staffLookupResult ? 1 : 0,
}),
}),
}),
}),
staff,
user,
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
and: vi.fn((..._clauses: unknown[]) => ({})),
sql: vi.fn((..._args: unknown[]) => ({})),
};
});
@@ -123,8 +87,6 @@ vi.mock("../db", () => {
function resetMocks() {
staffLookupResult = null;
managerFallbackResult = MANAGER;
userLookupResult = null;
_insertedStaff = null;
}
/** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */
@@ -134,7 +96,7 @@ function buildApp(
) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("jwtPayload", { sub: userLookupResult?.id ?? staffLookupResult?.userId ?? "unknown-sub" });
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
await next();
});
app.use("*", middleware);
@@ -240,50 +202,6 @@ describe("resolveStaffMiddleware", () => {
const body = await res.json();
expect(body.error).toMatch(/no staff records found/i);
});
it("auto-provision: creates groomer staff record on first login when Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-new", name: "New User", email: "newuser@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff).not.toBeNull();
expect(capturedStaff!.role).toBe("groomer");
expect(capturedStaff!.userId).toBe("ba-user-new");
expect(capturedStaff!.name).toBe("New User");
expect(capturedStaff!.email).toBe("newuser@example.com");
expect(capturedStaff!.isSuperUser).toBe(false);
});
it("auto-provision: falls back to email prefix when user has no name", async () => {
staffLookupResult = null;
userLookupResult = { id: "ba-user-noname", name: null, email: "firstlogin@example.com" };
let capturedStaff: StaffRow | null = null;
const app = buildApp(resolveStaffMiddleware, (c) => {
capturedStaff = c.get("staff");
return c.json({ ok: true });
});
const res = await app.request("/test");
expect(res.status).toBe(200);
expect(capturedStaff!.name).toBe("firstlogin");
});
it("auto-provision: returns 403 when no staff record and no Better-Auth user exists", async () => {
staffLookupResult = null;
userLookupResult = null;
const app = buildApp(resolveStaffMiddleware);
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/no staff record found for authenticated user/i);
});
});
// ─── requireRole tests ────────────────────────────────────────────────────────
+1 -28
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff, user } from "../db/index.js";
import { and, eq, getDb, sql, staff } from "../db/index.js";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -110,33 +110,6 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-provision: no staff record exists for this user at all, but a valid
// Better-Auth user session exists (jwt.sub = user.id from user table).
// Create a minimal groomer staff record on first login.
const [userRow] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, jwt.sub))
.limit(1);
if (userRow) {
const [newStaff] = await db
.insert(staff)
.values({
name: userRow.name ?? jwt.email?.split("@")[0] ?? "Unknown",
email: userRow.email ?? jwt.email ?? "",
userId: jwt.sub,
role: "groomer",
isSuperUser: false,
active: true,
})
.returning();
if (!newStaff) {
return c.json({ error: "Internal error: staff record creation failed" }, 500);
}
c.set("staff", newStaff);
await next();
return;
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+1
View File
@@ -1,2 +1,3 @@
packages:
- "apps/*"
- "packages/*"