diff --git a/__tests__/api/generate-auth.test.ts b/__tests__/api/generate-auth.test.ts index 01abe29..3458fec 100644 --- a/__tests__/api/generate-auth.test.ts +++ b/__tests__/api/generate-auth.test.ts @@ -41,6 +41,10 @@ jest.mock("@/lib/book-utils", () => ({ })), })) +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"), +})) + describe("Generate API Auth", () => { const mockCookies = { getAll: jest.fn(), @@ -101,6 +105,7 @@ describe("Generate API Auth", () => { anonymousId: "anon-123", }), url: "http://localhost/api/books/generate", + headers: new Headers(), } as unknown as Request await POST(request) @@ -131,6 +136,7 @@ describe("Generate API Auth", () => { anonymousId: "anon-123", }), url: "http://localhost/api/books/generate", + headers: new Headers(), } as unknown as Request await POST(request) diff --git a/__tests__/api/generate-location.test.ts b/__tests__/api/generate-location.test.ts new file mode 100644 index 0000000..5b28769 --- /dev/null +++ b/__tests__/api/generate-location.test.ts @@ -0,0 +1,115 @@ +import { POST } from "@/app/api/books/generate/route" +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" +import { cookies } from "next/headers" +import { getWhimsicalLocation } from "@/lib/location-utils" + +// Mock Supabase +jest.mock("@supabase/auth-helpers-nextjs", () => ({ + createRouteHandlerClient: jest.fn(), +})) + +jest.mock("@supabase/supabase-js", () => ({ + createClient: jest.fn(() => ({ + from: jest.fn(), // We won't strictly test the admin client anymore as we switched to route handler client + })), +})) + +// Mock Next.js headers +jest.mock("next/headers", () => ({ + cookies: jest.fn(), +})) + +// Mock internal libs +jest.mock("@/lib/id_generator", () => ({ + generateBookId: jest.fn().mockResolvedValue("TEST-CODE-LOC"), +})) +jest.mock("@/lib/book-utils", () => ({ + parseBookMetadata: jest.fn(() => ({ + title: "Test Book Location", + author: "Test Author", + cover_url: "http://example.com/cover.jpg", + isbn: "0000000001", + })), +})) + +// Mock location utils specifically to verify usage +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn(), +})) + +describe("Generate API Location Privacy", () => { + const mockCookies = { + getAll: jest.fn(), + get: jest.fn(), + } + const mockSupabase = { + auth: { + getUser: jest.fn(), + }, + from: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + ;(cookies as jest.Mock).mockResolvedValue(mockCookies) + ;(createRouteHandlerClient as jest.Mock).mockReturnValue(mockSupabase) + ;(getWhimsicalLocation as jest.Mock).mockResolvedValue("Whimsical Town, USA") + + // Mock auth + mockSupabase.auth.getUser.mockResolvedValue({ + data: { user: { id: "user-loc-123" } }, + error: null, + }) + + // Mock DB calls + const mockInsertSightings = jest.fn().mockResolvedValue({ error: null }) + const mockInsertBooks = jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: { id: "book-loc-123" }, error: null }), + }), + }) + + mockSupabase.from.mockImplementation((table) => { + if (table === "books") return { insert: mockInsertBooks } + if (table === "sightings") return { insert: mockInsertSightings } + return { select: jest.fn() } + }) + + // Attach helper to access mocks + // @ts-ignore + mockSupabase._mockInsertBooks = mockInsertBooks + // @ts-ignore + mockSupabase._mockInsertSightings = mockInsertSightings + }) + + it("should use whimsical location instead of raw coordinates in DB inserts", async () => { + const request = { + json: jest.fn().mockResolvedValue({ + book: { title: "Test Book" }, + location: { lat: 38.9, long: -77.0 }, + anonymousId: "anon-loc-123", + }), + url: "http://localhost/api/books/generate", + } as unknown as Request + + await POST(request) + + // Verify getWhimsicalLocation was called with correct coords + expect(getWhimsicalLocation).toHaveBeenCalledWith(38.9, -77.0) + + // Verify Books Insert + // @ts-ignore + const bookInsertCall = mockSupabase._mockInsertBooks.mock.calls[0][0] + expect(bookInsertCall.location).toBe("Whimsical Town, USA") + // Ensure we still store raw coords for mapping + expect(bookInsertCall.lat).toBe(38.9) + expect(bookInsertCall.lon).toBe(-77.0) + + // Verify Sightings Insert + // @ts-ignore + const sightingInsertCall = mockSupabase._mockInsertSightings.mock.calls[0][0] + expect(sightingInsertCall.location).toBe("Whimsical Town, USA") + expect(sightingInsertCall.lat).toBe(38.9) + expect(sightingInsertCall.lon).toBe(-77.0) + }) +}) diff --git a/__tests__/app/api/books/generate/route.test.ts b/__tests__/app/api/books/generate/route.test.ts index 23d642b..2de8170 100644 --- a/__tests__/app/api/books/generate/route.test.ts +++ b/__tests__/app/api/books/generate/route.test.ts @@ -28,6 +28,13 @@ jest.mock("@supabase/auth-helpers-nextjs", () => ({ getSession: jest.fn().mockResolvedValue({ data: { session: null } }), getUser: jest.fn().mockResolvedValue({ data: { user: null } }), }, + from: jest.fn().mockReturnValue({ + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + single: jest.fn().mockResolvedValue({ data: { id: "test-id" }, error: null }), + }), + }), + }), }), })) @@ -47,6 +54,7 @@ jest.mock("@/lib/id_generator", () => ({ generateBookId: jest.fn().mockResolvedValue("TEST-CODE"), })) + jest.mock("@/lib/book-utils", () => ({ parseBookMetadata: jest.fn().mockReturnValue({ title: "Test Title", @@ -56,6 +64,10 @@ jest.mock("@/lib/book-utils", () => ({ }), })) +jest.mock("@/lib/location-utils", () => ({ + getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"), +})) + describe("POST /api/books/generate", () => { it("should generate a book code successfully", async () => { const request = new Request("http://localhost:3000/api/books/generate", { diff --git a/__tests__/lib/location-utils.test.ts b/__tests__/lib/location-utils.test.ts new file mode 100644 index 0000000..906a20f --- /dev/null +++ b/__tests__/lib/location-utils.test.ts @@ -0,0 +1,92 @@ + +import { getWhimsicalLocation } from "@/lib/location-utils" + +// Mock global fetch +global.fetch = jest.fn() + +describe("getWhimsicalLocation", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should return 'Near [Neighbourhood] in [City]' when both are present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + neighbourhood: "Cooktown", + town: "Herndon", + country: "United States", + }, + }), + }) + + const result = await getWhimsicalLocation(38.9, -77.0) + expect(result).toBe("Near Cooktown in Herndon") + // Verify zoom level change + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("zoom=14"), + expect.any(Object) + ) + }) + + it("should return 'Near [Neighbourhood]' when only neighbourhood is present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + suburb: "Suburbtown", // Using suburb as partial alias for neighbourhood logic + }, + }), + }) + + const result = await getWhimsicalLocation(10, 20) + expect(result).toBe("Near Suburbtown") + }) + + it("should return '[City]' when only city is present", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + city: "Buffalo", + }, + }), + }) + + const result = await getWhimsicalLocation(10, 20) + expect(result).toBe("Buffalo") + }) + + it("should return 'The Wilds' when no relevant address parts are found", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ + address: { + country: "Nowhere Land", + }, + }), + }) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) + + it("should return 'The Wilds' on fetch failure (api error)", async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) + + it("should return 'The Wilds' on network exception", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error("Network Error")) + + const result = await getWhimsicalLocation(0, 0) + expect(result).toBe("The Wilds") + }) +}) diff --git a/app/api/books/generate/route.ts b/app/api/books/generate/route.ts index d28f8c8..9f384f5 100644 --- a/app/api/books/generate/route.ts +++ b/app/api/books/generate/route.ts @@ -7,6 +7,7 @@ import { createClient } from "@supabase/supabase-js" import { parseBookMetadata } from "@/lib/book-utils" import { OpenLibraryDoc, getBookCover } from "@/lib/openLibrary" import { BookMetadata } from "@/lib/types" +import { getWhimsicalLocation } from "@/lib/location-utils" export const dynamic = "force-dynamic" @@ -32,14 +33,8 @@ export async function POST(request: Request) { // 2. Generate Code const code = await generateBookId(lat, long) - // 3. Initialize Admin Client for persistence (bypassing RLS for anonymous inserts) - // Use DB_KEY as the service role key - const adminSupabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.DB_KEY!, { - auth: { - persistSession: false, - autoRefreshToken: false, - }, - }) + // 3. (Admin Client already imported as adminSupabase) + // Removed local initialization that used incorrect key. // 4. Prepare Metadata // The 'book' object might be an OpenLibraryDoc or a GoogleBookData object @@ -54,7 +49,10 @@ export async function POST(request: Request) { } // 5. Persist Book - const { data, error } = await adminSupabase + // 5. Persist Book (Using mapped client to respect RLS) + const whimsicalLocation = await getWhimsicalLocation(lat, long) + + const { data, error } = await supabase .from("books") .insert({ code, @@ -64,7 +62,7 @@ export async function POST(request: Request) { author: author, isbn: isbn, cover_url: cover_link, - location: `${lat},${long}`, + location: whimsicalLocation, }) .select() .single() @@ -75,11 +73,11 @@ export async function POST(request: Request) { } // 4. Create Initial Sighting - const { error: sightingError } = await adminSupabase.from("sightings").insert({ + const { error: sightingError } = await supabase.from("sightings").insert({ book_id: data.id, lat, lon: long, - location: `${lat},${long}`, + location: whimsicalLocation, sighting_type: "REGISTER", // If user is logged in, associate with them. Otherwise anonymous. user_id: userId, diff --git a/app/page.tsx b/app/page.tsx index bb5a334..c9738e2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,8 +65,10 @@ export default async function Home() { const sightings = rawSightings.map((sighting) => { if (sighting.user_id && usersMap.has(sighting.user_id)) { const fullEmail = usersMap.get(sighting.user_id) || "" - // Security: Mask the email server-side, only exposing the username part. - const maskedEmail = fullEmail.split("@")[0] + // Security: Obfuscate the email server-side. + // Format: first 3 chars + ... + last 3 chars of username. + const username = fullEmail.split("@")[0] + const maskedEmail = `${username.slice(0, 3)}...${username.slice(-3)}` return { ...sighting, user: { diff --git a/lib/location-utils.ts b/lib/location-utils.ts new file mode 100644 index 0000000..5a9a31d --- /dev/null +++ b/lib/location-utils.ts @@ -0,0 +1,54 @@ +/** + * Fetches a "whimsical" location name (suburb, town, city) from a lat/long pair + * using OpenStreetMap's Nominatim API. + * + * This is used to obfuscate precise coordinates in public feeds. + */ +export async function getWhimsicalLocation(lat: number, lon: number): Promise { + try { + const res = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=14`, + { + headers: { + // Nominatim requires a User-Agent. + "User-Agent": "TaleTrail/1.0 (taletrail.org / taletrail@chrislates.com)", + }, + next: { + // Cache for a long time to avoid rate limits on known coords + revalidate: 86400, + }, + } + ) + + if (!res.ok) { + console.warn("Nominatim API Error:", res.status, res.statusText) + return "The Wilds" + } + + const data = await res.json() + const addr = data.address || {} + + // Extract relevant parts + const neighbourhood = addr.neighbourhood || addr.suburb || addr.quarter + const city = addr.town || addr.city || addr.village || addr.municipality + const county = addr.county + + // Format: "Near [Neighbourhood] in [City]" or just "[City]" + let name = "The Wilds" + + if (neighbourhood && city) { + name = `Near ${neighbourhood} in ${city}` + } else if (neighbourhood) { + name = `Near ${neighbourhood}` + } else if (city) { + name = city + } else if (county) { + name = county + } + + return name + } catch (error) { + console.error("Failed to fetch whimsical location:", error) + return "The Wilds" + } +} diff --git a/lib/supabase-admin.ts b/lib/supabase-admin.ts old mode 100755 new mode 100644 index 6427d0f..d2c632e --- a/lib/supabase-admin.ts +++ b/lib/supabase-admin.ts @@ -4,7 +4,7 @@ import { createClient } from "@supabase/supabase-js" // Bypasses RLS. Use with caution. export const adminSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.DB_KEY!, + process.env.DB_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.DB_KEY!, { auth: { persistSession: false,