From 054015eb9840a561d18ee8f8abb99c25f914538d Mon Sep 17 00:00:00 2001 From: MCMXC <16797721+mcmxcdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:11:59 -0600 Subject: [PATCH 01/10] fix(map): remove unused formId variable in location-event-form Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 1 + apps/admin/.env.cloud-run.example | 2 +- apps/admin/.env.example | 3 +- apps/admin/scripts/cloud-run-env.sh | 1 - apps/admin/src/app/api/upload-logo/route.ts | 29 +- apps/admin/src/env.ts | 3 + apps/admin/src/lib/storage.ts | 12 + apps/map/.env.cloud-run.example | 5 +- apps/map/.env.example | 9 +- apps/map/package.json | 1 + apps/map/scripts/cloud-run-env.sh | 5 +- .../_components/forms/location-event-form.tsx | 21 +- apps/map/src/app/api/upload-logo/route.ts | 111 ++++--- apps/map/src/env.ts | 5 +- apps/map/src/lib/logging.ts | 11 + apps/map/src/lib/storage.ts | 12 + apps/map/src/utils/image/upload-logo.ts | 26 +- apps/me/.env.cloud-run.example | 2 +- apps/me/.env.example | 4 +- apps/me/README.md | 2 +- apps/me/docs/SECURITY.md | 20 +- apps/me/scripts/cloud-run-env.sh | 2 +- apps/me/src/app/api/profile/avatar/route.ts | 8 +- apps/me/src/app/api/profile/route.ts | 13 +- apps/me/src/env.ts | 4 + apps/me/src/lib/gcs.ts | 7 +- apps/me/src/lib/storage.ts | 12 + docs/LOCAL_DEV_DOCKER.md | 17 +- packages/storage/src/index.ts | 2 + packages/storage/src/public-images.test.ts | 279 ++++++++++++++++++ packages/storage/src/public-images.ts | 162 ++++++++++ pnpm-lock.yaml | 3 + scripts/local-setup.sh | 32 +- 33 files changed, 646 insertions(+), 180 deletions(-) create mode 100644 apps/admin/src/lib/storage.ts create mode 100644 apps/map/src/lib/logging.ts create mode 100644 apps/map/src/lib/storage.ts create mode 100644 apps/me/src/lib/storage.ts create mode 100644 packages/storage/src/public-images.test.ts create mode 100644 packages/storage/src/public-images.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d928946c..96b300cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ env: GOOGLE_LOGO_BUCKET_PRIVATE_KEY: mock-private-key GOOGLE_LOGO_BUCKET_CLIENT_EMAIL: mock@example.iam.gserviceaccount.com GOOGLE_LOGO_BUCKET_BUCKET_NAME: mock-bucket + GCS_CREDENTIALS: eyJjbGllbnRfZW1haWwiOiJtb2NrQGV4YW1wbGUuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJwcml2YXRlX2tleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbmZha2Vcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tIn0= # API (mocks) API_KEY: ci-api-key-placeholder SUPER_ADMIN_API_KEY: ci-super-admin-key-placeholder diff --git a/apps/admin/.env.cloud-run.example b/apps/admin/.env.cloud-run.example index 24c0508b..0d6c392a 100644 --- a/apps/admin/.env.cloud-run.example +++ b/apps/admin/.env.cloud-run.example @@ -16,7 +16,7 @@ F3_MAP_BASE_URL=https://map.f3nation.com F3_CHANNEL=prod OAUTH_CLIENT_ID=f3-admin-prod OAUTH_REDIRECT_URI=https://admin.f3nation.com/api/auth/callback -GCS_BUCKET=f3-public-images +GCS_EMULATOR_HOST= # Pino log level: trace | debug | info | warn | error | fatal (default: info). LOG_LEVEL=info \ No newline at end of file diff --git a/apps/admin/.env.example b/apps/admin/.env.example index c3c699f1..6c01f0c5 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -16,8 +16,7 @@ AUTH_PROVIDER_URL=http://localhost:3004 F3_API_BASE_URL=http://localhost:3001/v1 # Google Cloud Storage (logo uploads — uses local GCS emulator) -GCS_BUCKET=f3-public-images -GCS_CREDENTIALS=local-placeholder +GCS_CREDENTIALS=local-placeholder-not-used-with-emulator GCS_EMULATOR_HOST=localhost:9023 # App diff --git a/apps/admin/scripts/cloud-run-env.sh b/apps/admin/scripts/cloud-run-env.sh index 2814fba1..2e19fd56 100644 --- a/apps/admin/scripts/cloud-run-env.sh +++ b/apps/admin/scripts/cloud-run-env.sh @@ -49,7 +49,6 @@ ENV_FILE_VARS=( F3_CHANNEL OAUTH_CLIENT_ID OAUTH_REDIRECT_URI - GCS_BUCKET ) diff --git a/apps/admin/src/app/api/upload-logo/route.ts b/apps/admin/src/app/api/upload-logo/route.ts index ac6b63cc..754c991e 100644 --- a/apps/admin/src/app/api/upload-logo/route.ts +++ b/apps/admin/src/app/api/upload-logo/route.ts @@ -1,10 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { prepareImageForStorage, uploadFile } from "@acme/storage"; - import { requireAccessToken } from "~/lib/auth/server"; import { logError } from "~/lib/logging"; +import { storage } from "~/lib/storage"; const ALLOWED_TYPES = new Set([ "image/jpeg", @@ -14,6 +13,15 @@ const ALLOWED_TYPES = new Set([ ]); const MAX_SIZE = 10 * 1024 * 1024; // 10 MB +function parseOptionalSize( + sizeRaw: FormDataEntryValue | null, +): number | undefined | "invalid" { + if (!sizeRaw) return undefined; + const parsed = Number(sizeRaw); + if (!Number.isFinite(parsed) || parsed <= 0) return "invalid"; + return parsed; +} + export async function POST(request: NextRequest) { await requireAccessToken(); @@ -26,7 +34,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - if (!orgIdRaw || isNaN(Number(orgIdRaw))) { + const orgIdNum = Number(orgIdRaw); + if (!orgIdRaw || !Number.isInteger(orgIdNum) || orgIdNum <= 0) { return NextResponse.json({ error: "Invalid orgId" }, { status: 400 }); } @@ -44,16 +53,16 @@ export async function POST(request: NextRequest) { ); } - const orgId = Number(orgIdRaw); - const dimension = sizeRaw ? Number(sizeRaw) : 640; + const size = parseOptionalSize(sizeRaw); + if (size === "invalid") { + return NextResponse.json({ error: "Invalid size" }, { status: 400 }); + } + + const orgId = orgIdNum; try { const buffer = Buffer.from(await fileEntry.arrayBuffer()); - const jpeg = await prepareImageForStorage(buffer, { - width: dimension, - height: dimension, - }); - const url = await uploadFile(`org-logos/${orgId}.jpg`, jpeg, "image/jpeg"); + const url = await storage.uploadOrgLogo(orgId, buffer, { size }); return NextResponse.json({ url }); } catch (err) { diff --git a/apps/admin/src/env.ts b/apps/admin/src/env.ts index 89faaee8..1125a0cd 100644 --- a/apps/admin/src/env.ts +++ b/apps/admin/src/env.ts @@ -18,6 +18,9 @@ export const env = createEnv({ OAUTH_CLIENT_ID: z.string().min(1), OAUTH_CLIENT_SECRET: z.string().min(1), OAUTH_REDIRECT_URI: z.string().url(), + // Base64-encoded service-account JSON for GCS public-image uploads. + GCS_CREDENTIALS: z.string().min(1), + GCS_EMULATOR_HOST: z.string().optional(), }, client: {}, // With experimental__runtimeEnv (Next >= 13.4.4) only client + shared vars diff --git a/apps/admin/src/lib/storage.ts b/apps/admin/src/lib/storage.ts new file mode 100644 index 00000000..f1fd292b --- /dev/null +++ b/apps/admin/src/lib/storage.ts @@ -0,0 +1,12 @@ +import "server-only"; +import { createPublicImageStorage } from "@acme/storage"; +import { env } from "~/env"; + +function deriveStorageChannel(channel: string): "staging" | "prod" { + return channel === "prod" ? "prod" : "staging"; +} + +export const storage = createPublicImageStorage({ + channel: deriveStorageChannel(env.F3_CHANNEL), + credentials: env.GCS_CREDENTIALS, +}); diff --git a/apps/map/.env.cloud-run.example b/apps/map/.env.cloud-run.example index 2e7accff..ac5c8d79 100644 --- a/apps/map/.env.cloud-run.example +++ b/apps/map/.env.cloud-run.example @@ -10,8 +10,7 @@ DATABASE_URL=postgresql://user:pass@staging.db.f3nation.com:5432/f3_staging EMAIL_SERVER=smtp://apikey:SG.xxxx@smtp.sendgrid.net:587 F3_GOOGLE_API_KEY= F3_MAP_API_KEY= -GOOGLE_LOGO_BUCKET_CLIENT_EMAIL=photos-uploader@f3data.iam.gserviceaccount.com -GOOGLE_LOGO_BUCKET_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +GCS_CREDENTIALS= NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED= SUPER_ADMIN_API_KEY= TEST_DATABASE_URL=postgresql://user:pass@staging.db.f3nation.com:5432/f3_test @@ -25,8 +24,6 @@ F3_ADMIN_URL=https://staging.admin.f3nation.com F3_API_BASE_URL=https://staging.api.f3nation.com F3_CHANNEL=staging F3_MAP_BASE_URL=https://staging.map.f3nation.com -GOOGLE_LOGO_BUCKET_BUCKET_NAME=f3-logos -GOOGLE_LOGO_BUCKET_PROJECT_ID=f3data NEXT_PUBLIC_API_URL=https://staging.api.f3nation.com NEXT_PUBLIC_CHANNEL=staging NEXT_PUBLIC_GA_MEASUREMENT_ID= diff --git a/apps/map/.env.example b/apps/map/.env.example index a0561b9a..3fbf9d1e 100644 --- a/apps/map/.env.example +++ b/apps/map/.env.example @@ -39,13 +39,10 @@ F3_MAP_BASE_URL=http://localhost:3000 # -- Google Cloud Storage (GCS emulator on port 9023) ------------------------- # GCS_EMULATOR_HOST tells the upload route to use fake-gcs-server instead of -# the real GCS API. The three GOOGLE_LOGO_BUCKET_* vars must be non-empty to -# pass validation, but their values are ignored when the emulator is active. +# the real GCS API. GCS_CREDENTIALS must be non-empty to pass validation, but +# its value is ignored when the emulator is active. GCS_EMULATOR_HOST=localhost:9023 -GOOGLE_LOGO_BUCKET_BUCKET_NAME=f3-public-images -GOOGLE_LOGO_BUCKET_CLIENT_EMAIL=local@local.local -GOOGLE_LOGO_BUCKET_PRIVATE_KEY=local-placeholder-not-used-with-emulator -GOOGLE_LOGO_BUCKET_PROJECT_ID=f3-local +GCS_CREDENTIALS=local-placeholder-not-used-with-emulator # -- Client-side URLs (Next.js public vars) ----------------------------------- # NEXT_PUBLIC_API_URL and NEXT_PUBLIC_MAP_URL are NOT used by the map app at diff --git a/apps/map/package.json b/apps/map/package.json index afe73b15..08169829 100644 --- a/apps/map/package.json +++ b/apps/map/package.json @@ -18,6 +18,7 @@ "dependencies": { "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", + "@acme/storage": "workspace:^0.2.0", "@acme/db": "workspace:^0.1.0", "@acme/logger": "workspace:*", "@acme/mail": "workspace:*", diff --git a/apps/map/scripts/cloud-run-env.sh b/apps/map/scripts/cloud-run-env.sh index 779d7f4b..55bfe12b 100644 --- a/apps/map/scripts/cloud-run-env.sh +++ b/apps/map/scripts/cloud-run-env.sh @@ -40,8 +40,7 @@ declare -A SECRET_MAP=( [EMAIL_SERVER]="EMAIL_SERVER" [F3_GOOGLE_API_KEY]="F3_GOOGLE_API_KEY" [F3_MAP_API_KEY]="F3_MAP_API_KEY" - [GOOGLE_LOGO_BUCKET_CLIENT_EMAIL]="GOOGLE_LOGO_BUCKET_CLIENT_EMAIL" - [GOOGLE_LOGO_BUCKET_PRIVATE_KEY]="GOOGLE_LOGO_BUCKET_PRIVATE_KEY" + [GCS_CREDENTIALS]="GCS_CREDENTIALS" [NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED]="NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED" [SUPER_ADMIN_API_KEY]="SUPER_ADMIN_API_KEY" [TEST_DATABASE_URL]="TEST_DATABASE_URL" @@ -58,8 +57,6 @@ ENV_FILE_VARS=( F3_API_BASE_URL F3_CHANNEL F3_MAP_BASE_URL - GOOGLE_LOGO_BUCKET_BUCKET_NAME - GOOGLE_LOGO_BUCKET_PROJECT_ID NEXT_PUBLIC_API_URL NEXT_PUBLIC_CHANNEL NEXT_PUBLIC_GA_MEASUREMENT_ID diff --git a/apps/map/src/app/_components/forms/location-event-form.tsx b/apps/map/src/app/_components/forms/location-event-form.tsx index 473d65aa..b9c2be9f 100644 --- a/apps/map/src/app/_components/forms/location-event-form.tsx +++ b/apps/map/src/app/_components/forms/location-event-form.tsx @@ -16,7 +16,6 @@ import { toast } from "@acme/ui/toast"; import { orpc, useQuery } from "~/orpc/react"; import { useUpdateLocationFormContext } from "~/utils/forms"; -import { scaleAndCropImage } from "~/utils/image/scale-and-crop-image"; import { uploadLogo } from "~/utils/image/upload-logo"; import { mapStore } from "~/utils/store/map"; import { DebouncedImage } from "../debounced-image"; @@ -30,7 +29,6 @@ export const LocationEventForm = ({ isAdminForm?: boolean; }) => { const form = useUpdateLocationFormContext(); - const formId = form.watch("id"); const formRegionId = form.watch("regionId"); const formLocationId = form.watch("locationId"); const formAoId = form.watch("aoId"); @@ -432,27 +430,14 @@ export const LocationEventForm = ({ toast.error("Please select a region first"); return; } - console.log("files", e.target.files); const file = e.target.files?.[0]; if (!file) return; - const blob640 = await scaleAndCropImage(file, 640, 640); - if (!blob640) return; - const url640 = await uploadLogo({ - file: blob640, + const url = await uploadLogo({ + file, orgId: formRegionId, - requestId: formId, }); - onChange(url640); - const blob64 = await scaleAndCropImage(file, 64, 64); - if (blob64) { - await uploadLogo({ - file: blob64, - orgId: formRegionId, - requestId: formId, - size: 64, - }); - } + onChange(url); }} disabled={lt(formRegionId, 0)} className="flex-1" diff --git a/apps/map/src/app/api/upload-logo/route.ts b/apps/map/src/app/api/upload-logo/route.ts index 22ff0f82..a58cbf22 100644 --- a/apps/map/src/app/api/upload-logo/route.ts +++ b/apps/map/src/app/api/upload-logo/route.ts @@ -1,73 +1,68 @@ -import { GoogleAuth } from "google-auth-library"; import { NextResponse } from "next/server"; -import { env } from "~/env"; +import { logError } from "~/lib/logging"; +import { storage } from "~/lib/storage"; + +const ALLOWED_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); +const MAX_SIZE = 10 * 1024 * 1024; // 10 MB + +function parseOptionalSize( + sizeRaw: FormDataEntryValue | null, +): number | undefined | "invalid" { + if (!sizeRaw) return undefined; + const parsed = Number(sizeRaw); + if (!Number.isFinite(parsed) || parsed <= 0) return "invalid"; + return parsed; +} export async function POST(request: Request) { - try { - const formData = await request.formData(); - const file = formData.get("file") as File; - const orgId = formData.get("orgId") as string; - const requestId = formData.get("requestId") as string; - const size = formData.get("size") as string | undefined; + const formData = await request.formData(); + const file = formData.get("file"); + const orgIdRaw = formData.get("orgId"); + const sizeRaw = formData.get("size"); - if (!file || !orgId || !requestId) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); - } + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } - const filename = `${orgId}-${requestId}${size ? `-${size}` : ""}.${file.type.split("/")[1]}`; - const bucket = env.GOOGLE_LOGO_BUCKET_BUCKET_NAME; - const isEmulator = !!env.GCS_EMULATOR_HOST; - const gcsBase = isEmulator - ? `http://${env.GCS_EMULATOR_HOST}` - : "https://storage.googleapis.com"; + const orgIdNum = Number(orgIdRaw); + if (!orgIdRaw || !Number.isInteger(orgIdNum) || orgIdNum <= 0) { + return NextResponse.json({ error: "Invalid orgId" }, { status: 400 }); + } - let bearerToken: string; - if (isEmulator) { - bearerToken = "local-dev-token"; - } else { - const auth = new GoogleAuth({ - credentials: { - private_key: env.GOOGLE_LOGO_BUCKET_PRIVATE_KEY.replace( - /\\\n/g, - "\n", - ).replace(/\\n/g, "\n"), - client_email: env.GOOGLE_LOGO_BUCKET_CLIENT_EMAIL, - }, - scopes: ["https://www.googleapis.com/auth/cloud-platform"], - }); - const client = await auth.getClient(); - const tokenResult = await client.getAccessToken(); - if (!tokenResult.token) - throw new Error("GCS: failed to obtain access token"); - bearerToken = tokenResult.token; - } + if (!ALLOWED_TYPES.has(file.type)) { + return NextResponse.json( + { error: "Invalid file type. Allowed: jpeg, png, webp, gif" }, + { status: 400 }, + ); + } - const response = await fetch( - `${gcsBase}/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${filename}`, - { - method: "POST", - headers: { - Authorization: `Bearer ${bearerToken}`, - "Content-Type": file.type, - }, - body: await file.arrayBuffer(), - }, + if (file.size > MAX_SIZE) { + return NextResponse.json( + { error: "File too large. Maximum size is 10MB" }, + { status: 400 }, ); + } - if (!response.ok) { - const body = await response.text().catch(() => "(unreadable)"); - console.error(`GCS upload failed: HTTP ${response.status}`, body); - throw new Error(`Failed to upload to GCS: HTTP ${response.status}`); - } + const size = parseOptionalSize(sizeRaw); + if (size === "invalid") { + return NextResponse.json({ error: "Invalid size" }, { status: 400 }); + } + + const orgId = orgIdNum; - const publicUrl = isEmulator - ? `http://${env.GCS_EMULATOR_HOST}/${bucket}/${filename}` - : `https://storage.googleapis.com/${bucket}/${filename}`; + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const url = await storage.uploadOrgLogo(orgId, buffer, { size }); - return NextResponse.json({ url: publicUrl }); - } catch (error) { - console.error("Error uploading file:", error); + return NextResponse.json({ url }); + } catch (err) { + logError("map.logo.upload_failed", { orgId }, err); return NextResponse.json( { error: "Failed to upload file" }, { status: 500 }, diff --git a/apps/map/src/env.ts b/apps/map/src/env.ts index 674c638b..d8c3d27b 100644 --- a/apps/map/src/env.ts +++ b/apps/map/src/env.ts @@ -30,9 +30,8 @@ export const env = createEnv({ ), F3_MAP_BASE_URL: z.string().url(), GCS_EMULATOR_HOST: z.string().optional(), - GOOGLE_LOGO_BUCKET_BUCKET_NAME: z.string().min(1), - GOOGLE_LOGO_BUCKET_CLIENT_EMAIL: z.string().min(1), - GOOGLE_LOGO_BUCKET_PRIVATE_KEY: z.string().min(1), + // Base64-encoded service-account JSON for GCS public-image uploads. + GCS_CREDENTIALS: z.string().min(1), SUPER_ADMIN_API_KEY: z.string().min(1), }, /** diff --git a/apps/map/src/lib/logging.ts b/apps/map/src/lib/logging.ts new file mode 100644 index 00000000..4c82bb60 --- /dev/null +++ b/apps/map/src/lib/logging.ts @@ -0,0 +1,11 @@ +import { createLogger } from "@acme/logger"; + +export const { + logTrace, + logDebug, + logInfo, + logWarn, + logError, + logFatal, + logger, +} = createLogger("f3-map"); diff --git a/apps/map/src/lib/storage.ts b/apps/map/src/lib/storage.ts new file mode 100644 index 00000000..f1fd292b --- /dev/null +++ b/apps/map/src/lib/storage.ts @@ -0,0 +1,12 @@ +import "server-only"; +import { createPublicImageStorage } from "@acme/storage"; +import { env } from "~/env"; + +function deriveStorageChannel(channel: string): "staging" | "prod" { + return channel === "prod" ? "prod" : "staging"; +} + +export const storage = createPublicImageStorage({ + channel: deriveStorageChannel(env.F3_CHANNEL), + credentials: env.GCS_CREDENTIALS, +}); diff --git a/apps/map/src/utils/image/upload-logo.ts b/apps/map/src/utils/image/upload-logo.ts index b3cadc26..3a644514 100644 --- a/apps/map/src/utils/image/upload-logo.ts +++ b/apps/map/src/utils/image/upload-logo.ts @@ -1,21 +1,13 @@ -export const uploadLogo = async ({ +export async function uploadLogo({ file, orgId, - requestId, - size, }: { - file: Blob; + file: File | Blob; orgId: number; - requestId: string; - size?: number; -}) => { +}): Promise { const formData = new FormData(); formData.append("file", file); formData.append("orgId", orgId.toString()); - formData.append("requestId", requestId); - if (size) { - formData.append("size", size.toString()); - } const response = await fetch("/api/upload-logo", { method: "POST", @@ -23,10 +15,12 @@ export const uploadLogo = async ({ }); if (!response.ok) { - throw new Error("Failed to upload logo"); + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(data?.error ?? "Failed to upload logo"); } - console.log("response", response); - const { url } = (await response.json()) as { url: string }; - return url; -}; + const data = (await response.json()) as { url: string }; + return data.url; +} diff --git a/apps/me/.env.cloud-run.example b/apps/me/.env.cloud-run.example index 9ba411e3..83212581 100644 --- a/apps/me/.env.cloud-run.example +++ b/apps/me/.env.cloud-run.example @@ -6,7 +6,7 @@ OAUTH_CLIENT_SECRET= OAUTH_REDIRECT_URI=https://staging.me.f3nation.com/api/auth/callback AUTH_PROVIDER_URL=https://auth.f3nation.com F3_API_BASE_URL=https://staging.api.f3nation.com/v1 -GCS_BUCKET=f3-public-images-staging +F3_CHANNEL=staging GCS_CREDENTIALS= NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_SITE_URL=https://staging.me.f3nation.com diff --git a/apps/me/.env.example b/apps/me/.env.example index 73066154..459e7fa5 100644 --- a/apps/me/.env.example +++ b/apps/me/.env.example @@ -15,8 +15,10 @@ AUTH_PROVIDER_URL=http://localhost:3004 # F3 Nation API (local API on :3001) F3_API_BASE_URL=http://localhost:3001/v1 +# Channel (determines which GCS bucket is used for public images) +F3_CHANNEL=local + # Google Cloud Storage (avatar uploads) -GCS_BUCKET=f3-public-images GCS_CREDENTIALS="" GCS_EMULATOR_HOST=localhost:9023 diff --git a/apps/me/README.md b/apps/me/README.md index 23ed9574..61bbd0ae 100644 --- a/apps/me/README.md +++ b/apps/me/README.md @@ -122,7 +122,7 @@ Open [https://localhost:3003](https://localhost:3003). Accept the self-signed ce | `OAUTH_REDIRECT_URI` | OAuth callback URL | `https://localhost:3003/api/auth/callback` | | `AUTH_PROVIDER_URL` | F3 SSO base URL | `https://auth.f3nation.com` | | `F3_API_BASE_URL` | F3 API base URL (must include `/v1`) | `https://staging.api.f3nation.com/v1` | -| `GCS_BUCKET` | GCS bucket for avatars | `f3-public-images-staging` | +| `F3_CHANNEL` | Channel (`local` → staging bucket) | `local` | | `GCS_CREDENTIALS` | Base64-encoded GCS service account JSON | (from GCP) | | `NEXT_PUBLIC_SITE_URL` | Public URL of the app | `https://localhost:3003` | diff --git a/apps/me/docs/SECURITY.md b/apps/me/docs/SECURITY.md index f47dd805..4a4cf2fe 100644 --- a/apps/me/docs/SECURITY.md +++ b/apps/me/docs/SECURITY.md @@ -113,16 +113,16 @@ There is no app-specific bearer secret and no `X-User-Id` override in this flow. ### apps/me -| Variable | Purpose | Sensitivity | -| ---------------------- | ------------------------------- | ----------- | -| `OAUTH_CLIENT_ID` | OAuth client identifier | Low | -| `OAUTH_CLIENT_SECRET` | OAuth client secret | High | -| `OAUTH_REDIRECT_URI` | OAuth callback URL | Low | -| `AUTH_PROVIDER_URL` | Auth provider base URL | Low | -| `F3_API_BASE_URL` | API base URL | Low | -| `NEXT_PUBLIC_SITE_URL` | Public app origin | Low | -| `GCS_BUCKET` | Avatar upload bucket | Medium | -| `GCS_CREDENTIALS` | GCS service account credentials | Critical | +| Variable | Purpose | Sensitivity | +| ---------------------- | -------------------------------------- | ----------- | +| `OAUTH_CLIENT_ID` | OAuth client identifier | Low | +| `OAUTH_CLIENT_SECRET` | OAuth client secret | High | +| `OAUTH_REDIRECT_URI` | OAuth callback URL | Low | +| `AUTH_PROVIDER_URL` | Auth provider base URL | Low | +| `F3_API_BASE_URL` | API base URL | Low | +| `NEXT_PUBLIC_SITE_URL` | Public app origin | Low | +| `F3_CHANNEL` | Channel (`prod` / `staging` / `local`) | Medium | +| `GCS_CREDENTIALS` | GCS service account credentials | Critical | Legacy variables removed from apps/me: diff --git a/apps/me/scripts/cloud-run-env.sh b/apps/me/scripts/cloud-run-env.sh index defab3d0..30b5be10 100644 --- a/apps/me/scripts/cloud-run-env.sh +++ b/apps/me/scripts/cloud-run-env.sh @@ -41,7 +41,7 @@ ENV_FILE_VARS=( OAUTH_CLIENT_ID OAUTH_REDIRECT_URI F3_API_BASE_URL - GCS_BUCKET + F3_CHANNEL NEXT_PUBLIC_GA_MEASUREMENT_ID NEXT_PUBLIC_SITE_URL ) diff --git a/apps/me/src/app/api/profile/avatar/route.ts b/apps/me/src/app/api/profile/avatar/route.ts index beee2611..52bc2c43 100644 --- a/apps/me/src/app/api/profile/avatar/route.ts +++ b/apps/me/src/app/api/profile/avatar/route.ts @@ -24,14 +24,10 @@ function mapAvatarUploadError(err: unknown): { status: number; error: string } { }; } - if ( - lower.includes("gcs_credentials is not set") || - lower.includes("gcs_bucket is not set") - ) { + if (lower.includes("gcs_credentials is not set")) { return { status: 500, - error: - "Avatar storage is not configured. Set GCS_BUCKET and GCS_CREDENTIALS.", + error: "Avatar storage is not configured. Set GCS_CREDENTIALS.", }; } diff --git a/apps/me/src/app/api/profile/route.ts b/apps/me/src/app/api/profile/route.ts index c8f83aae..0aca1563 100644 --- a/apps/me/src/app/api/profile/route.ts +++ b/apps/me/src/app/api/profile/route.ts @@ -5,13 +5,7 @@ import { requireAuth } from "@/lib/auth/server"; import { getMyProfile, updateMyProfile } from "@/lib/api/client"; import type { UserMeta } from "@/lib/types"; import { logError } from "@/lib/logging"; - -// Enforce avatar host allow-list at this boundary so malformed avatarUrl -// values are rejected with a 400 before calling the underlying API. -const ALLOWED_AVATAR_HOST_PATTERN = - /^https:\/\/storage\.googleapis\.com\/f3-public-images(-staging)?\//; -const isAllowedAvatarUrl = (url: string): boolean => - ALLOWED_AVATAR_HOST_PATTERN.test(url); +import { storage } from "@/lib/storage"; const profileUpdateSchema = z .object({ @@ -24,7 +18,10 @@ const profileUpdateSchema = z avatarUrl: z .string() .url() - .refine(isAllowedAvatarUrl, "avatarUrl must be a GCS public-image URL") + .refine( + (url) => storage.isAllowedPublicImageUrl(url), + "avatarUrl must be a GCS public-image URL", + ) .nullable() .optional(), emergencyContact: z.string().max(200).nullable().optional(), diff --git a/apps/me/src/env.ts b/apps/me/src/env.ts index 59f52574..9e503f14 100644 --- a/apps/me/src/env.ts +++ b/apps/me/src/env.ts @@ -13,6 +13,10 @@ export const env = createEnv({ OAUTH_CLIENT_ID: z.string().min(1), OAUTH_CLIENT_SECRET: z.string().min(1), OAUTH_REDIRECT_URI: z.string().url(), + F3_CHANNEL: z.enum(["local", "ci", "branch", "dev", "staging", "prod"]), + // Base64-encoded service-account JSON for GCS public-image uploads. + GCS_CREDENTIALS: z.string().min(1), + GCS_EMULATOR_HOST: z.string().optional(), }, client: { NEXT_PUBLIC_SITE_URL: z.string().min(1), diff --git a/apps/me/src/lib/gcs.ts b/apps/me/src/lib/gcs.ts index ec34abd4..ef74ac6e 100644 --- a/apps/me/src/lib/gcs.ts +++ b/apps/me/src/lib/gcs.ts @@ -1,12 +1,9 @@ import "server-only"; -import { prepareImageForStorage, uploadFile } from "@acme/storage"; +import { storage } from "@/lib/storage"; export async function uploadAvatar( userId: number, file: Buffer, ): Promise { - const jpeg = await prepareImageForStorage(file, { width: 512, height: 512 }); - return uploadFile(`user-avatars/${userId}.jpg`, jpeg, "image/jpeg", { - cacheControl: "public, max-age=300", - }); + return storage.uploadUserAvatar(userId, file); } diff --git a/apps/me/src/lib/storage.ts b/apps/me/src/lib/storage.ts new file mode 100644 index 00000000..a64c938d --- /dev/null +++ b/apps/me/src/lib/storage.ts @@ -0,0 +1,12 @@ +import "server-only"; +import { createPublicImageStorage } from "@acme/storage"; +import { env } from "@/env"; + +function deriveStorageChannel(channel: string): "staging" | "prod" { + return channel === "prod" ? "prod" : "staging"; +} + +export const storage = createPublicImageStorage({ + channel: deriveStorageChannel(env.F3_CHANNEL), + credentials: env.GCS_CREDENTIALS, +}); diff --git a/docs/LOCAL_DEV_DOCKER.md b/docs/LOCAL_DEV_DOCKER.md index 4114299c..e220ed38 100644 --- a/docs/LOCAL_DEV_DOCKER.md +++ b/docs/LOCAL_DEV_DOCKER.md @@ -296,14 +296,13 @@ All outbound emails are captured by [Mailpit](https://mailpit.axllent.org/) — ### Google Cloud Storage (GCS emulator) -| Variable | Value | Meaning | -| --------------------------------- | ----------------------- | ------------------------------------------------------------------- | -| `GCS_EMULATOR_HOST` | `localhost:9023` | Tells the app to use the local emulator instead of real GCS | -| `GOOGLE_LOGO_BUCKET_PRIVATE_KEY` | `local-placeholder-...` | Required by env validation, but **ignored** when emulator is active | -| `GOOGLE_LOGO_BUCKET_CLIENT_EMAIL` | `local@local.local` | Same — ignored when emulator is active | -| `GOOGLE_LOGO_BUCKET_BUCKET_NAME` | `f3-public-images` | The bucket name used by both the emulator and real GCS | +| Variable | Value | Meaning | +| ------------------- | ------------------------------------------ | ------------------------------------------------------------------- | +| `GCS_EMULATOR_HOST` | `localhost:9023` | Tells the app to use the local emulator instead of real GCS | +| `GCS_CREDENTIALS` | `local-placeholder-not-used-with-emulator` | Required by env validation, but **ignored** when emulator is active | +| `F3_CHANNEL` | `local` | Selects staging bucket (`f3-public-images-staging`) for local dev | -When `GCS_EMULATOR_HOST` is set, the upload route skips Google authentication entirely and sends files directly to the local fake-gcs-server. Uploaded logos are stored in a Docker volume and served at `http://localhost:9023/f3-public-images/`. +When `GCS_EMULATOR_HOST` is set, the upload route skips Google authentication entirely and sends files directly to the local fake-gcs-server. Uploaded logos are stored in a Docker volume at canonical paths such as `org-logos/{orgId}.jpg` and served at `http://localhost:9023/f3-public-images-staging/`. ### Client-side URLs @@ -377,8 +376,8 @@ Logo uploads in the Map app are handled by the GCS emulator (`fake-gcs-server`) 1. When you upload a logo, the Map app sends the image to its `/api/upload-logo` route 2. The route detects `GCS_EMULATOR_HOST` in the env and calls the emulator instead of real GCS -3. The emulator stores the file in the `f3-public-images` bucket -4. The returned public URL points to `http://localhost:9023/f3-public-images/` +3. The emulator stores the file in the `f3-public-images-staging` bucket (local `F3_CHANNEL`) +4. The returned public URL points to `http://localhost:9023/f3-public-images-staging/org-logos/.jpg` ### Browsing stored files diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 2eb2904c..ba08a679 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -3,3 +3,5 @@ export type { UploadFileOptions } from "./upload"; export { deleteFile } from "./delete"; export { prepareImageForStorage } from "./resize"; export type { PrepareImageForStorageOptions } from "./resize"; +export { createPublicImageStorage } from "./public-images"; +export type { PublicImageStorage } from "./public-images"; diff --git a/packages/storage/src/public-images.test.ts b/packages/storage/src/public-images.test.ts new file mode 100644 index 00000000..adb6b072 --- /dev/null +++ b/packages/storage/src/public-images.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./resize", () => ({ + prepareImageForStorage: vi.fn(() => Promise.resolve(Buffer.from("fakejpeg"))), +})); + +import { createPublicImageStorage } from "./public-images"; + +const EMULATOR_HOST = "localhost:4443"; +const FAKE_CREDENTIALS = Buffer.from( + JSON.stringify({ + client_email: "svc@proj.iam.gserviceaccount.com", + private_key: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + }), +).toString("base64"); + +// --------------------------------------------------------------------------- +// isAllowedPublicImageUrl +// --------------------------------------------------------------------------- + +describe("isAllowedPublicImageUrl", () => { + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + + it("allows prod bucket URLs", () => { + expect( + storage.isAllowedPublicImageUrl( + "https://storage.googleapis.com/f3-public-images/org-logos/1.jpg", + ), + ).toBe(true); + }); + + it("allows staging bucket URLs", () => { + expect( + storage.isAllowedPublicImageUrl( + "https://storage.googleapis.com/f3-public-images-staging/user-avatars/2.jpg", + ), + ).toBe(true); + }); + + it("rejects other GCS URLs", () => { + expect( + storage.isAllowedPublicImageUrl( + "https://storage.googleapis.com/some-other-bucket/file.jpg", + ), + ).toBe(false); + }); + + it("rejects non-GCS URLs", () => { + expect( + storage.isAllowedPublicImageUrl("https://example.com/avatar.jpg"), + ).toBe(false); + }); + + it("rejects URLs that use prod bucket name as a prefix trick", () => { + expect( + storage.isAllowedPublicImageUrl( + "https://storage.googleapis.com/f3-public-images-evil/file.jpg", + ), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// uploadOrgLogo — emulator mode (prod channel) +// --------------------------------------------------------------------------- + +describe("uploadOrgLogo (emulator mode)", () => { + beforeEach(() => { + process.env.GCS_EMULATOR_HOST = EMULATOR_HOST; + }); + + afterEach(() => { + delete process.env.GCS_EMULATOR_HOST; + vi.restoreAllMocks(); + }); + + it("uploads to prod bucket and returns canonical URL", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response("{}", { status: 200 })), + ); + + const storage = createPublicImageStorage({ + channel: "prod", + credentials: FAKE_CREDENTIALS, + }); + const url = await storage.uploadOrgLogo(123, Buffer.from("img")); + expect(url).toBe( + `http://${EMULATOR_HOST}/f3-public-images/org-logos/123.jpg`, + ); + }); + + it("uploads to staging bucket and returns canonical URL", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response("{}", { status: 200 })), + ); + + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + const url = await storage.uploadOrgLogo(42, Buffer.from("img")); + expect(url).toBe( + `http://${EMULATOR_HOST}/f3-public-images-staging/org-logos/42.jpg`, + ); + }); + + it("throws when emulator returns non-2xx", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response("bucket not found", { status: 404 })), + ); + + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + await expect(storage.uploadOrgLogo(1, Buffer.from("img"))).rejects.toThrow( + "GCS emulator upload failed: HTTP 404 bucket not found", + ); + }); +}); + +// --------------------------------------------------------------------------- +// uploadOrgLogo — production mode (credentials validation) +// --------------------------------------------------------------------------- + +describe("uploadOrgLogo (production mode)", () => { + afterEach(() => { + delete process.env.GCS_EMULATOR_HOST; + vi.restoreAllMocks(); + }); + + it("throws when credentials JSON is missing required fields", async () => { + const badCreds = Buffer.from(JSON.stringify({ client_email: "" })).toString( + "base64", + ); + const storage = createPublicImageStorage({ + channel: "staging", + credentials: badCreds, + }); + await expect(storage.uploadOrgLogo(1, Buffer.from("img"))).rejects.toThrow( + "GCS_CREDENTIALS is missing required service account fields", + ); + }); +}); + +// --------------------------------------------------------------------------- +// uploadUserAvatar — emulator mode +// --------------------------------------------------------------------------- + +describe("uploadUserAvatar (emulator mode)", () => { + beforeEach(() => { + process.env.GCS_EMULATOR_HOST = EMULATOR_HOST; + }); + + afterEach(() => { + delete process.env.GCS_EMULATOR_HOST; + vi.restoreAllMocks(); + }); + + it("uploads to correct path and returns canonical URL", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + requestedUrls.push( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + return Promise.resolve(new Response("{}", { status: 200 })); + }); + + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + const url = await storage.uploadUserAvatar(7, Buffer.from("img")); + + expect(url).toBe( + `http://${EMULATOR_HOST}/f3-public-images-staging/user-avatars/7.jpg`, + ); + expect(requestedUrls[0]).toContain("name=user-avatars%2F7.jpg"); + }); +}); + +// --------------------------------------------------------------------------- +// deleteOrgLogo — emulator mode +// --------------------------------------------------------------------------- + +describe("deleteOrgLogo (emulator mode)", () => { + beforeEach(() => { + process.env.GCS_EMULATOR_HOST = EMULATOR_HOST; + }); + + afterEach(() => { + delete process.env.GCS_EMULATOR_HOST; + vi.restoreAllMocks(); + }); + + it("does not throw when emulator returns 404", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response(null, { status: 404 })), + ); + + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + await expect(storage.deleteOrgLogo(5)).resolves.toBeUndefined(); + }); + + it("throws when emulator returns non-404 error", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response("internal error", { status: 500 })), + ); + + const storage = createPublicImageStorage({ + channel: "prod", + credentials: FAKE_CREDENTIALS, + }); + await expect(storage.deleteOrgLogo(5)).rejects.toThrow( + "GCS emulator delete failed: HTTP 500 internal error", + ); + }); +}); + +// --------------------------------------------------------------------------- +// deleteUserAvatar — emulator mode +// --------------------------------------------------------------------------- + +describe("deleteUserAvatar (emulator mode)", () => { + beforeEach(() => { + process.env.GCS_EMULATOR_HOST = EMULATOR_HOST; + }); + + afterEach(() => { + delete process.env.GCS_EMULATOR_HOST; + vi.restoreAllMocks(); + }); + + it("resolves on 200", async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response(null, { status: 200 })), + ); + + const storage = createPublicImageStorage({ + channel: "staging", + credentials: FAKE_CREDENTIALS, + }); + await expect(storage.deleteUserAvatar(3)).resolves.toBeUndefined(); + }); + + it("uses correct path in delete request", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + requestedUrls.push( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + return Promise.resolve(new Response(null, { status: 200 })); + }); + + const storage = createPublicImageStorage({ + channel: "prod", + credentials: FAKE_CREDENTIALS, + }); + await storage.deleteUserAvatar(9); + expect(requestedUrls[0]).toContain( + `/f3-public-images/o/user-avatars%2F9.jpg`, + ); + }); +}); diff --git a/packages/storage/src/public-images.ts b/packages/storage/src/public-images.ts new file mode 100644 index 00000000..dd5bcbcf --- /dev/null +++ b/packages/storage/src/public-images.ts @@ -0,0 +1,162 @@ +import { Storage } from "@google-cloud/storage"; + +import { emulatorFetch, getEmulatorHost } from "./emulator"; +import { prepareImageForStorage } from "./resize"; + +const BUCKETS = { + prod: "f3-public-images", + staging: "f3-public-images-staging", +} as const; + +export interface PublicImageStorage { + uploadOrgLogo( + orgId: number, + file: Buffer, + options?: { size?: number }, + ): Promise; + deleteOrgLogo(orgId: number): Promise; + uploadUserAvatar( + userId: number, + file: Buffer, + options?: { size?: number }, + ): Promise; + deleteUserAvatar(userId: number): Promise; + isAllowedPublicImageUrl(url: string): boolean; +} + +export function createPublicImageStorage(config: { + channel: "staging" | "prod"; + credentials: string; +}): PublicImageStorage { + const bucket = BUCKETS[config.channel]; + + let storageClient: Storage | null = null; + + function getClient(): Storage { + if (storageClient) return storageClient; + let creds: { client_email: string; private_key: string }; + try { + creds = JSON.parse( + Buffer.from(config.credentials, "base64").toString(), + ) as { client_email: string; private_key: string }; + } catch (err) { + const message = err instanceof Error ? err.message : "invalid JSON"; + throw new Error(`Invalid GCS_CREDENTIALS payload: ${message}`); + } + if (!creds.client_email || !creds.private_key) { + throw new Error( + "GCS_CREDENTIALS is missing required service account fields", + ); + } + storageClient = new Storage({ credentials: creds }); + return storageClient; + } + + async function uploadToBucket( + path: string, + data: Buffer, + contentType: string, + ): Promise { + const emulatorHost = getEmulatorHost(); + + if (emulatorHost) { + const encodedPath = encodeURIComponent(path); + const response = await emulatorFetch( + `http://${emulatorHost}/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${encodedPath}`, + { + method: "POST", + headers: { "Content-Type": contentType }, + body: data as unknown as Uint8Array, + }, + ); + if (!response.ok) { + const body = await response.text().catch(() => "(unreadable)"); + throw new Error( + `GCS emulator upload failed: HTTP ${response.status} ${body}`, + ); + } + return `http://${emulatorHost}/${bucket}/${path}`; + } + + const blob = getClient().bucket(bucket).file(path); + await blob.save(data, { + resumable: false, + metadata: { + contentType, + cacheControl: "public, max-age=300", + }, + }); + return `https://storage.googleapis.com/${bucket}/${path}`; + } + + async function deleteFromBucket(path: string): Promise { + const emulatorHost = getEmulatorHost(); + + if (emulatorHost) { + const encodedPath = encodeURIComponent(path); + const response = await emulatorFetch( + `http://${emulatorHost}/storage/v1/b/${bucket}/o/${encodedPath}`, + { method: "DELETE" }, + ); + if (!response.ok && response.status !== 404) { + const body = await response.text().catch(() => "(unreadable)"); + throw new Error( + `GCS emulator delete failed: HTTP ${response.status} ${body}`, + ); + } + return; + } + + await getClient() + .bucket(bucket) + .file(path) + .delete({ ignoreNotFound: true }); + } + + function isAllowedPublicImageUrl(url: string): boolean { + if ( + url.startsWith(`https://storage.googleapis.com/${BUCKETS.prod}/`) || + url.startsWith(`https://storage.googleapis.com/${BUCKETS.staging}/`) + ) { + return true; + } + const emulatorHost = getEmulatorHost(); + if (emulatorHost) { + return ( + url.startsWith(`http://${emulatorHost}/${BUCKETS.prod}/`) || + url.startsWith(`http://${emulatorHost}/${BUCKETS.staging}/`) + ); + } + return false; + } + + return { + async uploadOrgLogo(orgId, file, options) { + const size = options?.size ?? 640; + const jpg = await prepareImageForStorage(file, { + width: size, + height: size, + }); + return uploadToBucket(`org-logos/${orgId}.jpg`, jpg, "image/jpeg"); + }, + + async deleteOrgLogo(orgId) { + await deleteFromBucket(`org-logos/${orgId}.jpg`); + }, + + async uploadUserAvatar(userId, file, options) { + const size = options?.size ?? 512; + const jpg = await prepareImageForStorage(file, { + width: size, + height: size, + }); + return uploadToBucket(`user-avatars/${userId}.jpg`, jpg, "image/jpeg"); + }, + + async deleteUserAvatar(userId) { + await deleteFromBucket(`user-avatars/${userId}.jpg`); + }, + + isAllowedPublicImageUrl, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9fd4bd1..c59d9246 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,9 @@ importers: '@acme/shared': specifier: workspace:^0.1.0 version: link:../../packages/shared + '@acme/storage': + specifier: workspace:^0.2.0 + version: link:../../packages/storage '@acme/tailwind-config': specifier: workspace:^0.1.0 version: link:../../tooling/tailwind diff --git a/scripts/local-setup.sh b/scripts/local-setup.sh index 7ee01588..7e811e43 100755 --- a/scripts/local-setup.sh +++ b/scripts/local-setup.sh @@ -15,7 +15,7 @@ set -e -BUCKET_NAME="${GOOGLE_LOGO_BUCKET_BUCKET_NAME:-f3-public-images}" +BUCKETS=(f3-public-images f3-public-images-staging) GCS_PORT=9023 PG_CONTAINER=f3-postgres @@ -83,20 +83,22 @@ for i in $(seq 1 30); do sleep 1 done -# ── Step 4: Create GCS bucket ──────────────────────────────────────────────── -echo " → Creating GCS bucket '${BUCKET_NAME}'..." -status=$(curl -s -o /tmp/gcs-bucket-create.out -w "%{http_code}" -X POST "http://localhost:${GCS_PORT}/storage/v1/b" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"${BUCKET_NAME}\"}") -if [ "$status" = "200" ] || [ "$status" = "201" ]; then - echo " Bucket '${BUCKET_NAME}' created." -elif [ "$status" = "409" ]; then - echo " Bucket already exists — continuing." -else - echo " ERROR: failed to create bucket (HTTP ${status})." - cat /tmp/gcs-bucket-create.out - exit 1 -fi +# ── Step 4: Create GCS buckets ─────────────────────────────────────────────── +for BUCKET_NAME in "${BUCKETS[@]}"; do + echo " → Creating GCS bucket '${BUCKET_NAME}'..." + status=$(curl -s -o /tmp/gcs-bucket-create.out -w "%{http_code}" -X POST "http://localhost:${GCS_PORT}/storage/v1/b" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"${BUCKET_NAME}\"}") + if [ "$status" = "200" ] || [ "$status" = "201" ]; then + echo " Bucket '${BUCKET_NAME}' created." + elif [ "$status" = "409" ]; then + echo " Bucket '${BUCKET_NAME}' already exists — continuing." + else + echo " ERROR: failed to create bucket '${BUCKET_NAME}' (HTTP ${status})." + cat /tmp/gcs-bucket-create.out + exit 1 + fi +done # ── Step 5: Run migrations ──────────────────────────────────────────────────── echo " → Running database migrations..." From 474c12ffa1373238769935a3c762686118ba5f1b Mon Sep 17 00:00:00 2001 From: MCMXC <16797721+mcmxcdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:43:05 -0600 Subject: [PATCH 02/10] fix(map): updates --- apps/map/package.json | 2 +- apps/me/src/env.ts | 1 + apps/me/vitest.config.ts | 4 ++-- packages/env/src/index.ts | 3 --- packages/storage/src/env.ts | 4 +++- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/map/package.json b/apps/map/package.json index 08169829..60466740 100644 --- a/apps/map/package.json +++ b/apps/map/package.json @@ -18,11 +18,11 @@ "dependencies": { "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", - "@acme/storage": "workspace:^0.2.0", "@acme/db": "workspace:^0.1.0", "@acme/logger": "workspace:*", "@acme/mail": "workspace:*", "@acme/shared": "workspace:^0.1.0", + "@acme/storage": "workspace:^0.2.0", "@acme/tailwind-config": "workspace:^0.1.0", "@acme/ui": "workspace:^0.1.0", "@acme/validators": "workspace:^0.1.0", diff --git a/apps/me/src/env.ts b/apps/me/src/env.ts index 9e503f14..f540481d 100644 --- a/apps/me/src/env.ts +++ b/apps/me/src/env.ts @@ -32,5 +32,6 @@ export const env = createEnv({ skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION || + process.env.NODE_ENV === "test" || process.env.npm_lifecycle_event === "lint", }); diff --git a/apps/me/vitest.config.ts b/apps/me/vitest.config.ts index 9166bdf0..ebfd7d5a 100644 --- a/apps/me/vitest.config.ts +++ b/apps/me/vitest.config.ts @@ -18,8 +18,8 @@ export default defineConfig({ thresholds: { autoUpdate: true, statements: 30.76, - branches: 33.9, - functions: 17.14, + branches: 82.89, + functions: 49.29, lines: 31.69, }, }, diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index e43f29db..c6e83fbc 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -29,9 +29,6 @@ export const env = createEnv({ .default("info"), EMAIL_ADMIN_DESTINATIONS: z.string().min(1), EMAIL_REGION_IN_A_BOX_CC: z.string().min(1).optional(), - GOOGLE_LOGO_BUCKET_PRIVATE_KEY: z.string().min(1), - GOOGLE_LOGO_BUCKET_CLIENT_EMAIL: z.string().min(1), - GOOGLE_LOGO_BUCKET_BUCKET_NAME: z.string().min(1), TEST_DATABASE_URL: z.string().min(1).optional(), API_KEY: z.string().min(1), SUPER_ADMIN_API_KEY: z.string().min(1).optional(), diff --git a/packages/storage/src/env.ts b/packages/storage/src/env.ts index 72334b0b..b97f56ab 100644 --- a/packages/storage/src/env.ts +++ b/packages/storage/src/env.ts @@ -3,7 +3,9 @@ import { z } from "zod"; export const env = createEnv({ server: { - GCS_BUCKET: z.string().min(1), + // Used only by the low-level uploadFile/deleteFile helpers. Apps using + // createPublicImageStorage derive the bucket from channel and do not set this. + GCS_BUCKET: z.string().min(1).optional(), // Base64-encoded service-account JSON. Shape (client_email / private_key) // is validated when the client is constructed in ./client. Eager validation // here is only skipped for test/CI (not GCS_EMULATOR_HOST), so emulator/local From b534a7e986f613547b612225fb77118dca756f0b Mon Sep 17 00:00:00 2001 From: MCMXC <16797721+mcmxcdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:43:31 -0600 Subject: [PATCH 03/10] fix(repo): implement review feedback --- .../_components/forms/location-event-form.tsx | 3 - apps/me/README.md | 20 ++--- apps/me/docs/SECURITY.md | 20 ++--- apps/me/src/app/api/profile/avatar/route.ts | 5 +- docs/LOCAL_DEV_DOCKER.md | 46 +++++----- packages/storage/src/public-images.test.ts | 85 ++++++++++++------- packages/storage/src/public-images.ts | 11 +++ packages/storage/src/storage.test.ts | 4 + 8 files changed, 116 insertions(+), 78 deletions(-) diff --git a/apps/map/src/app/_components/forms/location-event-form.tsx b/apps/map/src/app/_components/forms/location-event-form.tsx index b9c2be9f..8222e8fa 100644 --- a/apps/map/src/app/_components/forms/location-event-form.tsx +++ b/apps/map/src/app/_components/forms/location-event-form.tsx @@ -32,8 +32,6 @@ export const LocationEventForm = ({ const formRegionId = form.watch("regionId"); const formLocationId = form.watch("locationId"); const formAoId = form.watch("aoId"); - console.log("form eventTypeIds", form.getValues().eventTypeIds); - // Get form values const { data: regionsResponse } = useQuery( orpc.map.location.regions.queryOptions(), @@ -150,7 +148,6 @@ export const LocationEventForm = ({ control={form.control} name="eventTypeIds" render={({ field, fieldState }) => { - console.log("eventTypes", eventTypes, field.value); return (
| +| **GCS Emulator** | Emulates Google Cloud Storage for logo uploads | | +| **Mailpit** | Catches all outbound emails so you can read them | | Your app servers (Map, API, Auth) still run natively on your machine with `pnpm dev`. Docker only manages the stateful infrastructure. @@ -115,14 +115,16 @@ docker run hello-world git: Saving and submitting code; working with GitHub git is how you interact with GitHub from your instance of the code. You often already have git. If not, here's a link. -1. Go to https://git-scm.com/install/ and download the correct version. During install, if you don't know what anything means, just leave defaults and keep hitting Next. +1. Go to and download the correct version. During install, if you don't know what anything means, just leave defaults and keep hitting Next. +
VS Code: User interface work coding You will need a code editor in order to edit code! The instructions assume you will be using VS Code. If you have a different IDE, you'll have to adjust accordingly. -1. Go to https://code.visualstudio.com/download and download the correct version. +1. Go to and download the correct version. +
@@ -208,14 +210,14 @@ The above command will install code if you don't have it already and then open y pnpm dev ``` -| App | URL | -| -------- | --------------------- | -| Map | http://localhost:3000 | -| API | http://localhost:3001 | -| Admin | http://localhost:3002 | -| Me | http://localhost:3003 | -| Auth | http://localhost:3004 | -| Homepage | http://localhost:3005 | +| App | URL | +| -------- | ----------------------- | +| Map | | +| API | | +| Admin | | +| Me | | +| Auth | | +| Homepage | | --- @@ -285,7 +287,7 @@ The `5433` port is where the Docker Postgres container is exposed on your machin ### Email (Mailpit) -All outbound emails are captured by [Mailpit](https://mailpit.axllent.org/) — no emails actually leave your machine. Open http://localhost:8025 to read any email the app sends (password resets, notifications, etc.). +All outbound emails are captured by [Mailpit](https://mailpit.axllent.org/) — no emails actually leave your machine. Open to read any email the app sends (password resets, notifications, etc.). | Variable | Value | Meaning | | -------------------------- | ------------------------ | ----------------------------------------------------------------------- | @@ -328,7 +330,7 @@ These tell each Next.js app where to find the other apps. Don't change these unl ### Browse the database with Adminer -1. Open http://localhost:8080 +1. Open 2. Fill in the login form: - **System**: PostgreSQL - **Server**: `f3-postgres` @@ -384,10 +386,10 @@ Logo uploads in the Map app are handled by the GCS emulator (`fake-gcs-server`) To list all uploaded files in the emulator: ```bash -curl http://localhost:9023/storage/v1/b/f3-public-images/o | jq '.items[].name' +curl http://localhost:9023/storage/v1/b/f3-public-images-staging/o | jq '.items[].name' ``` -Individual files are directly accessible at `http://localhost:9023/f3-public-images/`. +Individual files are directly accessible at `http://localhost:9023/f3-public-images-staging/`. ### Resetting uploaded files @@ -406,7 +408,7 @@ pnpm docker:up # then re-create the bucket: curl -X POST http://localhost:9023/storage/v1/b \ -H "Content-Type: application/json" \ - -d '{"name": "f3-public-images"}' + -d '{"name": "f3-public-images-staging"}' ``` --- @@ -491,7 +493,7 @@ The bucket needs to be created after the emulator starts. Run: ```bash curl -X POST http://localhost:9023/storage/v1/b \ -H "Content-Type: application/json" \ - -d '{"name": "f3-public-images"}' + -d '{"name": "f3-public-images-staging"}' ``` ### Migrations failing diff --git a/packages/storage/src/public-images.test.ts b/packages/storage/src/public-images.test.ts index adb6b072..12fc082e 100644 --- a/packages/storage/src/public-images.test.ts +++ b/packages/storage/src/public-images.test.ts @@ -76,11 +76,13 @@ describe("uploadOrgLogo (emulator mode)", () => { afterEach(() => { delete process.env.GCS_EMULATOR_HOST; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("uploads to prod bucket and returns canonical URL", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response("{}", { status: 200 })), + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("{}", { status: 200 }))), ); const storage = createPublicImageStorage({ @@ -94,8 +96,9 @@ describe("uploadOrgLogo (emulator mode)", () => { }); it("uploads to staging bucket and returns canonical URL", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response("{}", { status: 200 })), + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("{}", { status: 200 }))), ); const storage = createPublicImageStorage({ @@ -109,8 +112,11 @@ describe("uploadOrgLogo (emulator mode)", () => { }); it("throws when emulator returns non-2xx", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response("bucket not found", { status: 404 })), + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(new Response("bucket not found", { status: 404 })), + ), ); const storage = createPublicImageStorage({ @@ -131,6 +137,7 @@ describe("uploadOrgLogo (production mode)", () => { afterEach(() => { delete process.env.GCS_EMULATOR_HOST; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("throws when credentials JSON is missing required fields", async () => { @@ -159,20 +166,24 @@ describe("uploadUserAvatar (emulator mode)", () => { afterEach(() => { delete process.env.GCS_EMULATOR_HOST; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("uploads to correct path and returns canonical URL", async () => { const requestedUrls: string[] = []; - globalThis.fetch = vi.fn((input: RequestInfo | URL) => { - requestedUrls.push( - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url, - ); - return Promise.resolve(new Response("{}", { status: 200 })); - }); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + requestedUrls.push( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + return Promise.resolve(new Response("{}", { status: 200 })); + }), + ); const storage = createPublicImageStorage({ channel: "staging", @@ -199,11 +210,13 @@ describe("deleteOrgLogo (emulator mode)", () => { afterEach(() => { delete process.env.GCS_EMULATOR_HOST; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("does not throw when emulator returns 404", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response(null, { status: 404 })), + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), ); const storage = createPublicImageStorage({ @@ -214,8 +227,11 @@ describe("deleteOrgLogo (emulator mode)", () => { }); it("throws when emulator returns non-404 error", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response("internal error", { status: 500 })), + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(new Response("internal error", { status: 500 })), + ), ); const storage = createPublicImageStorage({ @@ -240,11 +256,13 @@ describe("deleteUserAvatar (emulator mode)", () => { afterEach(() => { delete process.env.GCS_EMULATOR_HOST; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("resolves on 200", async () => { - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response(null, { status: 200 })), + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))), ); const storage = createPublicImageStorage({ @@ -256,16 +274,19 @@ describe("deleteUserAvatar (emulator mode)", () => { it("uses correct path in delete request", async () => { const requestedUrls: string[] = []; - globalThis.fetch = vi.fn((input: RequestInfo | URL) => { - requestedUrls.push( - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url, - ); - return Promise.resolve(new Response(null, { status: 200 })); - }); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + requestedUrls.push( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + ); + return Promise.resolve(new Response(null, { status: 200 })); + }), + ); const storage = createPublicImageStorage({ channel: "prod", diff --git a/packages/storage/src/public-images.ts b/packages/storage/src/public-images.ts index dd5bcbcf..4973580b 100644 --- a/packages/storage/src/public-images.ts +++ b/packages/storage/src/public-images.ts @@ -130,8 +130,16 @@ export function createPublicImageStorage(config: { return false; } + function assertPositiveIntegerId(label: "orgId" | "userId", value: number) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return value; + } + return { async uploadOrgLogo(orgId, file, options) { + assertPositiveIntegerId("orgId", orgId); const size = options?.size ?? 640; const jpg = await prepareImageForStorage(file, { width: size, @@ -141,10 +149,12 @@ export function createPublicImageStorage(config: { }, async deleteOrgLogo(orgId) { + assertPositiveIntegerId("orgId", orgId); await deleteFromBucket(`org-logos/${orgId}.jpg`); }, async uploadUserAvatar(userId, file, options) { + assertPositiveIntegerId("userId", userId); const size = options?.size ?? 512; const jpg = await prepareImageForStorage(file, { width: size, @@ -154,6 +164,7 @@ export function createPublicImageStorage(config: { }, async deleteUserAvatar(userId) { + assertPositiveIntegerId("userId", userId); await deleteFromBucket(`user-avatars/${userId}.jpg`); }, diff --git a/packages/storage/src/storage.test.ts b/packages/storage/src/storage.test.ts index c1298966..373596ce 100644 --- a/packages/storage/src/storage.test.ts +++ b/packages/storage/src/storage.test.ts @@ -184,6 +184,10 @@ describe("deleteFile (emulator mode)", () => { // --------------------------------------------------------------------------- describe("getStorage", () => { + beforeEach(() => { + vi.resetModules(); + }); + afterEach(() => { delete process.env.GCS_CREDENTIALS; vi.resetModules(); From 9eb0e2b4ba85941c670a0901b91d8a53b57d693f Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 08:17:48 -0500 Subject: [PATCH 04/10] test(me): getting up to coverage --- apps/me/__tests__/lib/storage.test.ts | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/me/__tests__/lib/storage.test.ts diff --git a/apps/me/__tests__/lib/storage.test.ts b/apps/me/__tests__/lib/storage.test.ts new file mode 100644 index 00000000..0a23365e --- /dev/null +++ b/apps/me/__tests__/lib/storage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("lib/storage", () => { + it("uses prod storage channel when F3_CHANNEL is prod", async () => { + vi.resetModules(); + + const createPublicImageStorage = vi.fn(() => ({ + isAllowedPublicImageUrl: () => true, + })); + + vi.doMock("@acme/storage", () => ({ + createPublicImageStorage, + })); + + vi.doMock("@/env", () => ({ + env: { + F3_CHANNEL: "prod", + GCS_CREDENTIALS: "cred-prod", + }, + })); + + await import("@/lib/storage"); + + expect(createPublicImageStorage).toHaveBeenCalledWith({ + channel: "prod", + credentials: "cred-prod", + }); + }); + + it("falls back to staging channel for non-prod F3_CHANNEL values", async () => { + vi.resetModules(); + + const createPublicImageStorage = vi.fn(() => ({ + isAllowedPublicImageUrl: () => true, + })); + + vi.doMock("@acme/storage", () => ({ + createPublicImageStorage, + })); + + vi.doMock("@/env", () => ({ + env: { + F3_CHANNEL: "preview", + GCS_CREDENTIALS: "cred-staging", + }, + })); + + await import("@/lib/storage"); + + expect(createPublicImageStorage).toHaveBeenCalledWith({ + channel: "staging", + credentials: "cred-staging", + }); + }); +}); From 9ef0ab2f3e7d1f9ad4f61bd05eea01f747076440 Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 08:28:07 -0500 Subject: [PATCH 05/10] test(me): getting more up to coverage --- apps/me/__tests__/api/profile.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/me/__tests__/api/profile.test.ts b/apps/me/__tests__/api/profile.test.ts index 5b160ff8..58d92557 100644 --- a/apps/me/__tests__/api/profile.test.ts +++ b/apps/me/__tests__/api/profile.test.ts @@ -156,6 +156,24 @@ describe("Profile API route", () => { }); describe("PATCH /api/profile", () => { + it("returns 400 for malformed JSON payload", async () => { + vi.mocked(requireAuth).mockResolvedValue(mockSession); + + const { PATCH } = await import("@/app/api/profile/route"); + const req = new NextRequest("http://localhost/api/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: "{ bad json", + }); + + const response = await PATCH(req); + + expect(response.status).toBe(400); + const data = (await response.json()) as { error: string }; + expect(data.error).toBe("Invalid JSON payload"); + expect(updateMyProfile).not.toHaveBeenCalled(); + }); + it("updates basic fields", async () => { vi.mocked(requireAuth).mockResolvedValue(mockSession); vi.mocked(updateMyProfile).mockResolvedValue({ From 44e972fd11ad07438c0ee57ce69da36bd7ac4c34 Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 08:41:15 -0500 Subject: [PATCH 06/10] test(map): getting up to coverage --- apps/map/__tests__/lib/storage.test.ts | 55 ++++++++++++++ .../__tests__/utils/image/upload-logo.test.ts | 75 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 apps/map/__tests__/lib/storage.test.ts create mode 100644 apps/map/__tests__/utils/image/upload-logo.test.ts diff --git a/apps/map/__tests__/lib/storage.test.ts b/apps/map/__tests__/lib/storage.test.ts new file mode 100644 index 00000000..76bbed88 --- /dev/null +++ b/apps/map/__tests__/lib/storage.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("map storage bootstrap", () => { + it("uses prod storage channel when F3_CHANNEL is prod", async () => { + vi.resetModules(); + + const createPublicImageStorage = vi.fn(() => ({ + isAllowedPublicImageUrl: () => true, + })); + + vi.doMock("@acme/storage", () => ({ + createPublicImageStorage, + })); + + vi.doMock("~/env", () => ({ + env: { + F3_CHANNEL: "prod", + GCS_CREDENTIALS: "cred-prod", + }, + })); + + await import("~/lib/storage"); + + expect(createPublicImageStorage).toHaveBeenCalledWith({ + channel: "prod", + credentials: "cred-prod", + }); + }); + + it("falls back to staging for non-prod channels", async () => { + vi.resetModules(); + + const createPublicImageStorage = vi.fn(() => ({ + isAllowedPublicImageUrl: () => true, + })); + + vi.doMock("@acme/storage", () => ({ + createPublicImageStorage, + })); + + vi.doMock("~/env", () => ({ + env: { + F3_CHANNEL: "preview", + GCS_CREDENTIALS: "cred-staging", + }, + })); + + await import("~/lib/storage"); + + expect(createPublicImageStorage).toHaveBeenCalledWith({ + channel: "staging", + credentials: "cred-staging", + }); + }); +}); diff --git a/apps/map/__tests__/utils/image/upload-logo.test.ts b/apps/map/__tests__/utils/image/upload-logo.test.ts new file mode 100644 index 00000000..dd2dd7f9 --- /dev/null +++ b/apps/map/__tests__/utils/image/upload-logo.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; + +import { uploadLogo } from "~/utils/image/upload-logo"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("uploadLogo", () => { + it("returns url on successful upload", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ url: "https://example.com/logo.jpg" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ), + ), + ); + + const result = await uploadLogo({ + file: new Blob(["abc"], { type: "image/png" }), + orgId: 42, + }); + + expect(result).toBe("https://example.com/logo.jpg"); + }); + + it("throws API-provided error message on failed upload", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ error: "Upload denied" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ), + ), + ); + + await expect( + uploadLogo({ + file: new Blob(["abc"], { type: "image/png" }), + orgId: 42, + }), + ).rejects.toThrow("Upload denied"); + }); + + it("throws fallback error when failed upload body is unreadable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response("not json", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ), + ), + ); + + await expect( + uploadLogo({ + file: new Blob(["abc"], { type: "image/png" }), + orgId: 42, + }), + ).rejects.toThrow("Failed to upload logo"); + }); +}); From 1a367c47d60b72777863daff1d4a4064ebfdfe05 Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 09:31:04 -0500 Subject: [PATCH 07/10] test(me): back to me --- apps/me/__tests__/lib/auth/tokens.test.ts | 73 +++++++++++++++++++++++ apps/me/__tests__/lib/gcs.test.ts | 29 +++++++++ apps/me/vitest.config.ts | 4 +- 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 apps/me/__tests__/lib/gcs.test.ts diff --git a/apps/me/__tests__/lib/auth/tokens.test.ts b/apps/me/__tests__/lib/auth/tokens.test.ts index 85970e0e..f74d9c9b 100644 --- a/apps/me/__tests__/lib/auth/tokens.test.ts +++ b/apps/me/__tests__/lib/auth/tokens.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { isAccessTokenExpired, parseAccessTokenPayload, + verifyAccessToken, verifyAccessTokenPayload, } from "@/lib/auth/tokens"; @@ -173,3 +174,75 @@ describe("verifyAccessTokenPayload", () => { expect(result).toBeNull(); }); }); + +describe("verifyAccessToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns false for expired token without calling jwtVerify", async () => { + const expiredToken = createToken({ sub: "42", exp: 1 }); + + const result = await verifyAccessToken(expiredToken); + + expect(result).toBe(false); + expect(jwtVerify).not.toHaveBeenCalled(); + }); + + it("returns true when strict verify succeeds", async () => { + vi.mocked(jwtVerify).mockResolvedValueOnce({ + payload: { sub: "42", exp: Math.floor(Date.now() / 1000) + 3600 }, + protectedHeader: { alg: "RS256" }, + } as never); + + const result = await verifyAccessToken(createValidToken()); + + expect(result).toBe(true); + expect(jwtVerify).toHaveBeenCalledTimes(1); + }); + + it("returns true when strict verify fails and fallback client_id matches", async () => { + vi.mocked(jwtVerify) + .mockRejectedValueOnce(new Error("audience mismatch")) + .mockResolvedValueOnce({ + payload: { + sub: "42", + exp: Math.floor(Date.now() / 1000) + 3600, + client_id: "test-client-id", + }, + protectedHeader: { alg: "RS256" }, + } as never); + + const result = await verifyAccessToken(createValidToken()); + + expect(result).toBe(true); + expect(jwtVerify).toHaveBeenCalledTimes(2); + }); + + it("returns false when fallback verify succeeds but client_id mismatches", async () => { + vi.mocked(jwtVerify) + .mockRejectedValueOnce(new Error("audience mismatch")) + .mockResolvedValueOnce({ + payload: { + sub: "42", + exp: Math.floor(Date.now() / 1000) + 3600, + client_id: "wrong-client", + }, + protectedHeader: { alg: "RS256" }, + } as never); + + const result = await verifyAccessToken(createValidToken()); + + expect(result).toBe(false); + }); + + it("returns false when both verification attempts throw", async () => { + vi.mocked(jwtVerify) + .mockRejectedValueOnce(new Error("sig invalid")) + .mockRejectedValueOnce(new Error("sig invalid")); + + const result = await verifyAccessToken(createValidToken()); + + expect(result).toBe(false); + }); +}); diff --git a/apps/me/__tests__/lib/gcs.test.ts b/apps/me/__tests__/lib/gcs.test.ts new file mode 100644 index 00000000..c56f0626 --- /dev/null +++ b/apps/me/__tests__/lib/gcs.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; + +const uploadUserAvatarMock = vi.fn(); + +vi.mock("@/lib/storage", () => ({ + storage: { + uploadUserAvatar: uploadUserAvatarMock, + }, +})); + +import { uploadAvatar } from "@/lib/gcs"; + +describe("uploadAvatar", () => { + it("delegates to storage.uploadUserAvatar and returns URL", async () => { + uploadUserAvatarMock.mockResolvedValueOnce( + "https://storage.googleapis.com/f3-public-images/user-avatars/42.jpg", + ); + + const result = await uploadAvatar(42, Buffer.from("image-bytes")); + + expect(uploadUserAvatarMock).toHaveBeenCalledWith( + 42, + Buffer.from("image-bytes"), + ); + expect(result).toBe( + "https://storage.googleapis.com/f3-public-images/user-avatars/42.jpg", + ); + }); +}); diff --git a/apps/me/vitest.config.ts b/apps/me/vitest.config.ts index ebfd7d5a..6e3cab99 100644 --- a/apps/me/vitest.config.ts +++ b/apps/me/vitest.config.ts @@ -18,8 +18,8 @@ export default defineConfig({ thresholds: { autoUpdate: true, statements: 30.76, - branches: 82.89, - functions: 49.29, + branches: 84.51, + functions: 52.11, lines: 31.69, }, }, From 6e84c51a877f6098a7131322d0ea01ae2f62af6d Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 11:14:53 -0500 Subject: [PATCH 08/10] chore(me,repo): update vitest coverage, fix failing tests --- apps/me/__tests__/lib/gcs.test.ts | 4 +- pnpm-lock.yaml | 70 ++++++++++++------------------- pnpm-workspace.yaml | 2 +- 3 files changed, 31 insertions(+), 45 deletions(-) diff --git a/apps/me/__tests__/lib/gcs.test.ts b/apps/me/__tests__/lib/gcs.test.ts index c56f0626..e396fb8a 100644 --- a/apps/me/__tests__/lib/gcs.test.ts +++ b/apps/me/__tests__/lib/gcs.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -const uploadUserAvatarMock = vi.fn(); +const { uploadUserAvatarMock } = vi.hoisted(() => ({ + uploadUserAvatarMock: vi.fn(), +})); vi.mock("@/lib/storage", () => ({ storage: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59d9246..a90dc0f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,8 +178,8 @@ catalogs: specifier: ^5.2.0 version: 5.2.0 '@vitest/coverage-v8': - specifier: ^4.1.8 - version: 4.1.8 + specifier: ^4.1.9 + version: 4.1.9 autoprefixer: specifier: ^10.5.0 version: 10.5.0 @@ -684,7 +684,7 @@ importers: version: 5.2.0(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) eslint: specifier: 'catalog:' version: 10.4.0(jiti@2.7.0) @@ -711,7 +711,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) apps/auth: dependencies: @@ -796,7 +796,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) autoprefixer: specifier: 'catalog:' version: 10.5.0(postcss@8.5.15) @@ -829,7 +829,7 @@ importers: version: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) apps/homepage: dependencies: @@ -1062,7 +1062,7 @@ importers: version: 5.2.0(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) autoprefixer: specifier: 'catalog:' version: 10.5.0(postcss@8.5.15) @@ -1095,7 +1095,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vitest-canvas-mock: specifier: 'catalog:' version: 1.1.4(vitest@4.1.9) @@ -1186,7 +1186,7 @@ importers: version: 5.2.0(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) autoprefixer: specifier: 'catalog:' version: 10.5.0(postcss@8.5.15) @@ -1213,7 +1213,7 @@ importers: version: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) packages/api: dependencies: @@ -1283,7 +1283,7 @@ importers: version: 4.17.24 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) dotenv-cli: specifier: 'catalog:' version: 7.4.4 @@ -1304,7 +1304,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) packages/auth: dependencies: @@ -1617,7 +1617,7 @@ importers: version: 24.12.4 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.9) + version: 4.1.9(vitest@4.1.9) eslint: specifier: 'catalog:' version: 10.4.0(jiti@2.7.0) @@ -1629,7 +1629,7 @@ importers: version: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) packages/ui: dependencies: @@ -1985,7 +1985,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) packages: @@ -5550,11 +5550,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/coverage-v8@4.1.8': - resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: - '@vitest/browser': 4.1.8 - vitest: 4.1.8 + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -5573,9 +5573,6 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/pretty-format@4.1.9': resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} @@ -5588,9 +5585,6 @@ packages: '@vitest/spy@4.1.9': resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} @@ -12684,10 +12678,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.8(vitest@4.1.9)': + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 ast-v8-to-istanbul: 1.0.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -12696,7 +12690,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/expect@4.1.9': dependencies: @@ -12723,10 +12717,6 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0) - '@vitest/pretty-format@4.1.8': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 @@ -12745,12 +12735,6 @@ snapshots: '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@vitest/utils@4.1.9': dependencies: '@vitest/pretty-format': 4.1.9 @@ -16632,9 +16616,9 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) @@ -16659,12 +16643,12 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.12.4 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) jsdom: 29.1.1 transitivePeerDependencies: - msw - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) @@ -16689,7 +16673,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.2 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) jsdom: 29.1.1 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6521580..daf5b1b4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,7 +63,7 @@ catalog: "@types/supercluster": ^7.1.3 "@vis.gl/react-google-maps": ^1.5.2 "@vitejs/plugin-react": ^5.2.0 - "@vitest/coverage-v8": ^4.1.8 + "@vitest/coverage-v8": ^4.1.9 autoprefixer: ^10.5.0 class-variance-authority: ^0.7.0 clsx: ^2.1.0 From eef518c398a8153324a32bc2f9f0f2de9abaea75 Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 11:39:16 -0500 Subject: [PATCH 09/10] test(me): somehow thresholds got set abnormally high, resetting --- apps/me/vitest.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/me/vitest.config.ts b/apps/me/vitest.config.ts index 6e3cab99..473c9e8a 100644 --- a/apps/me/vitest.config.ts +++ b/apps/me/vitest.config.ts @@ -17,10 +17,10 @@ export default defineConfig({ exclude: coverageExclude, thresholds: { autoUpdate: true, - statements: 30.76, - branches: 84.51, - functions: 52.11, - lines: 31.69, + statements: 32.67, + branches: 35.41, + functions: 18.48, + lines: 33.53, }, }, setupFiles: ["./vitest.setup.ts"], From 3af08a385ade42d2f743824bb3c8d0ac2e43b89e Mon Sep 17 00:00:00 2001 From: tackle Date: Fri, 19 Jun 2026 12:19:11 -0500 Subject: [PATCH 10/10] chore(me): reducing coverage to reality, need to work issue #481 --- apps/me/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/me/vitest.config.ts b/apps/me/vitest.config.ts index 473c9e8a..b33ee315 100644 --- a/apps/me/vitest.config.ts +++ b/apps/me/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ thresholds: { autoUpdate: true, statements: 32.67, - branches: 35.41, + branches: 35.03, functions: 18.48, lines: 33.53, },