-
Notifications
You must be signed in to change notification settings - Fork 0
Fix Ledger Privacy & Obfuscation #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
79cd6f5
88cdda2
62845a7
d90a2fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string> { | ||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+21
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||||
| console.warn("Nominatim API Error:", res.status, res.statusText) | ||||||||||||||||||||||||||||||
| return "The Wilds" | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+26
|
||||||||||||||||||||||||||||||
| } | |
| ) | |
| if (!res.ok) { | |
| console.warn("Nominatim API Error:", res.status, res.statusText) | |
| return `${lat.toFixed(2)}, ${lon.toFixed(2)}` | |
| } | |
| signal: AbortSignal.timeout(5000), | |
| } | |
| ) | |
| if (!res.ok) { | |
| console.warn("Nominatim API Error:", res.status, res.statusText) | |
| return `${lat.toFixed(2)}, ${lon.toFixed(2)}` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The email obfuscation logic has a bug with short usernames. For usernames with 6 or fewer characters, the slice operations will produce overlapping or duplicate characters. For example:
The logic should check the username length and handle short usernames appropriately, perhaps by showing them as "ab..." or similar for very short names.