diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index fc6e2c43..823d408d 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -2688,7 +2688,73 @@ browser-support decision nobody made. ~~**Hydration mismatch on `/kommt-bald` and `/api/cocktails`.**~~ Both pages logged `Hydration completed but contains mismatches.` in a local production build, identically on `main`. -- [ ] ⚠️ **Server-only helpers live in the shared `helpers/` barrel, and the fix is not a one-liner.** +- [x] ✅ **Moved both to `server/utils/`** — 2026-10-20. + + - **`ipProcessing`**: the vote route now hashes the IP once and passes only the hash. + `createRating` stores it as given, and `getRecentRatingByIP` became + `getRecentRatingByIPHash(podcast, ipHash, since)`. The raw IP no longer reaches the shared + composable. With static `node:crypto` and `node:net` imports, the function is synchronous, and + the dynamic-import workaround is gone. + - **`aiSpamFilter`**: moved unchanged, except that `ResponseSchema` went to + `server/utils/schema.ts` as `SpamFilterResponseSchema`. Exported from `server/utils/` under + its old, generic name, it would have become a server-wide auto-import. + + **Hashes are byte-identical**, which matters because stored ratings carry them and the cooldown + matches on them. Before the move, 13 inputs were hashed with the old helper (IPv4, IPv6 + compressed and full, `::1`, `::ffff:`, a zone index, whitespace, and three invalid inputs), and + the moved helper gives the same output for all of them. `test/ipProcessing.test.ts` now pins + those values, and the vote-route test asserts the exact hash, not just "some hash". A negative + control confirmed it: zeroing one octet differently made both hash-dependent route tests fail. + + **Verified beyond the gates:** both Vite warnings about `node:crypto` and `node:net` being + externalized for the browser are gone. The client bundle contains none of the moved code (no + `anonymizeAndHashIP`, no Gemini SDK). `/api/vote` and `/api/email` load in a production + server. + + **The preview test exposed a production outage, fixed in the same PR (#285).** Every vote with + an IP answered 500, in production too, since the cooldown lookup shipped in `cd9acca` + (2026-10-20). `useDirectus` talks to the CMS without a token, so the server ran the lookup as the + **public role**, and that role may create ratings but not read a rating's `ip` or `target`. The + CMS answered `403 FORBIDDEN`, and the lookup runs before the insert, so **no vote was stored**. The + 403 reproduces with a plain unauthenticated request. Vercel's error list stayed empty, because + the route catches the error and only logs it. + + `getRecentRatingByIPHash` and `updateRatingVote` moved to `useAuthenticatedDirectus()`, which + uses the API token. `createRating` stays on the public client, which is allowed to create. + Opening up the public role instead would have let anyone query the IP hashes. The route test + now mocks the two clients separately. Before, a single mock stood in for both, which is how + the tests stayed green over the outage. A negative control confirmed it: with the lookup back + on the public client, 4 tests fail. Locally, without a token, a vote now fails with + `NUXT_DIRECTUS_API_TOKEN not configured`, which shows the route takes the token path. + **CMS permissions this depends on.** Directus roles and policies are **not versioned**: + `directus-cms/schema.json` holds collections, fields and relations only. These grants were set + by hand in the Directus admin on 2026-10-21, on the role or policy behind + `NUXT_DIRECTUS_API_TOKEN`. Anyone rebuilding the CMS has to set them again: + + | collection | action | fields | used by | + | --- | --- | --- | --- | + | `ratings` | read | `id`, `up_or_down`, `ip`, `date_created`, `target` | `getRecentRatingByIPHash`, which filters on `ip`, `date_created` and `target` and returns `id` and `up_or_down` | + | `ratings_target` | read | `rating`, `target`, `collection` | the same lookup. `target` is a many-to-any relation, and `target:podcasts` goes through this junction | + | `podcasts` | read | `id` | the end of that filter | + | `ratings` | update | `up_or_down` | `updateRatingVote`, for an opposite vote within the cooldown | + + Creating a rating stays with the **public role**, which already had `create` on `ratings`. A + first preview test with only the code fix failed with `403` on `id` and `up_or_down`, now from the + token client. Directus reports the requested fields first, so the error does not list everything + that is missing. With all four grants, the preview stored a vote, and the opposite vote within + 10 minutes replaced it: **one** rating, with the second direction, as intended. + +- [ ] ⚠️ **IPv4-mapped IPv6 addresses all hash to the same value.** Found while pinning the hashes + above. `anonymizeAndHashIP` zeroes the last four groups of an IPv6 address, and in + `::ffff:a.b.c.d` those groups *are* the IPv4 address. So every IPv4-mapped address hashes like + `::1`, and the vote cooldown would treat all such voters as one person. **Probably not reachable + in production:** the route reads `x-forwarded-for`, which Vercel overwrites with the client's + public IP, so the `::ffff:` form only arrives through the `remoteAddress` fallback, which is used + locally. Not checked against stored ratings. The fix is to unwrap `::ffff:` to plain IPv4 before + anonymizing, but that **changes the hash for those inputs**, so it is deliberately left out of + the move, which had to keep every hash identical. + + ~~**Server-only helpers live in the shared `helpers/` barrel, and the fix is not a one-liner.**~~ The earlier version of this item said "server code should import it directly". That was wrong: the importer of `ipProcessing` is `composables/useDirectus.ts`, shared client/server code. Checked 2026-10-20: diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index defa7285..83187525 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -29,7 +29,6 @@ import type { TagItem, } from '~/types' import { DIRECTUS_CMS_URL, WEBSITE_URL } from './../config' -import { anonymizeAndHashIP } from './../helpers/' // This import needs to be relative/file-based // so that it can be resolved during the nuxt build process import { directus, type Collections } from './../services' @@ -921,11 +920,9 @@ export function useDirectus() { ], } + // Already anonymized and hashed by the server route; the raw IP never reaches this composable. if (metadata?.ip) { - const hashedIP = await anonymizeAndHashIP(metadata.ip) - if (hashedIP) { - payload.ip = hashedIP - } + payload.ip = metadata.ip } if (metadata?.user_agent) { @@ -940,34 +937,6 @@ export function useDirectus() { return await directus.request(createItem('ratings', payload)) } - // Latest rating of the podcast from the same anonymized, hashed IP created at or after `since`. - async function getRecentRatingByIP(podcast: DirectusPodcastItem, rawIP: string, since: Date) { - const hashedIP = await anonymizeAndHashIP(rawIP) - if (!hashedIP) { - return null - } - - const ratings = await directus.request( - readItems('ratings', { - filter: { - ip: { _eq: hashedIP }, - date_created: { _gte: since.toISOString() }, - // `target` is a many-to-any relation, so the filter has to name the collection. - target: { 'target:podcasts': { id: { _eq: podcast.id } } }, - } as QueryFilter, - fields: ['id', 'up_or_down'], - sort: ['-date_created'], - limit: 1, - }) - ) - - return ratings?.[0] ?? null - } - - async function updateRatingVote(rating: { id: string }, vote: 'up' | 'down') { - return await directus.request(updateItem('ratings', rating.id, { up_or_down: vote })) - } - async function addCommentToRating(rating: { id: string }, comment: string) { return await directus.request(updateItem('ratings', rating.id, { comment })) } @@ -1099,8 +1068,6 @@ export function useDirectus() { getProfileById, getTestimonials, createRating, - getRecentRatingByIP, - updateRatingVote, addCommentToRating, } } diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts index ad9fcd29..c7d260d0 100644 --- a/nuxt-app/helpers/index.ts +++ b/nuxt-app/helpers/index.ts @@ -11,5 +11,3 @@ export * from './resolveNewsLink' export * from './setCookie' export * from './sleep' export * from './trackGoal' -export * from './aiSpamFilter' -export * from './ipProcessing' diff --git a/nuxt-app/server/api/email.post.ts b/nuxt-app/server/api/email.post.ts index ca587adf..83f17763 100644 --- a/nuxt-app/server/api/email.post.ts +++ b/nuxt-app/server/api/email.post.ts @@ -1,5 +1,4 @@ -import { filterSpam } from '../../helpers' -import { EmailSchema, sendEmail } from '../utils' +import { EmailSchema, filterSpam, sendEmail } from '../utils' export default defineEventHandler(async (event) => { // Read body once and use for both honeypot check and validation diff --git a/nuxt-app/server/api/vote.post.ts b/nuxt-app/server/api/vote.post.ts index 646ad8c2..76969ff2 100644 --- a/nuxt-app/server/api/vote.post.ts +++ b/nuxt-app/server/api/vote.post.ts @@ -1,7 +1,7 @@ import { useDirectus } from '~/composables/useDirectus' import { VOTE_COOLDOWN_MINUTES } from '~/config' import { isbot } from 'isbot' -import { VoteSchema } from '../utils' +import { anonymizeAndHashIP, useAuthenticatedDirectus, VoteSchema } from '../utils' // The only path that writes a vote. The GET /podcast/[slug]/[up|down] links that // ship in RSS show notes no longer write — they redirect to the podcast page, @@ -39,10 +39,11 @@ export default defineEventHandler(async (event) => { // Split x-forwarded-for on comma and take the first entry (the client IP) const xForwardedFor = event.node.req.headers['x-forwarded-for'] as string | undefined const rawIP = xForwardedFor ? xForwardedFor.split(',')[0].trim() : event.node.req.socket.remoteAddress + const ipHash = rawIP ? anonymizeAndHashIP(rawIP) : undefined const referrer = event.node.req.headers['referer'] - if (rawIP) { - metadata['ip'] = rawIP + if (ipHash) { + metadata['ip'] = ipHash } if (referrer) { metadata['referer_url'] = referrer @@ -51,12 +52,14 @@ export default defineEventHandler(async (event) => { try { // A further vote from the same IP hash on the same episode within the cooldown replaces // the earlier one, so a corrected vote counts and a double click counts once. - if (rawIP) { + if (ipHash) { + // Reading and changing existing ratings needs the API token; creating one does not. + const authenticatedDirectus = useAuthenticatedDirectus() const since = new Date(Date.now() - VOTE_COOLDOWN_MINUTES * 60 * 1000) - const recentRating = await directus.getRecentRatingByIP(podcast, rawIP, since) + const recentRating = await authenticatedDirectus.getRecentRatingByIPHash(podcast, ipHash, since) if (recentRating) { if (recentRating.up_or_down !== direction) { - await directus.updateRatingVote(recentRating, direction) + await authenticatedDirectus.updateRatingVote(recentRating, direction) } return { id: recentRating.id } } diff --git a/nuxt-app/helpers/aiSpamFilter.ts b/nuxt-app/server/utils/aiSpamFilter.ts similarity index 91% rename from nuxt-app/helpers/aiSpamFilter.ts rename to nuxt-app/server/utils/aiSpamFilter.ts index 09f7104b..26fc978d 100644 --- a/nuxt-app/helpers/aiSpamFilter.ts +++ b/nuxt-app/server/utils/aiSpamFilter.ts @@ -1,11 +1,5 @@ import { GoogleGenerativeAI } from '@google/generative-ai' -import { z } from 'zod' - -export const ResponseSchema = z.object({ - confidenceScore: z.number().min(0).max(1), - isSpam: z.boolean(), - reason: z.string(), -}) +import { SpamFilterResponseSchema } from './schema' function getValidationPrompt(name: string, email: string, message: string): string { return ` @@ -65,7 +59,7 @@ export async function filterSpam(input: { name: string; email: string; message: .replace(/```json/g, '') .replace(/```/g, '') .trim() - const validation = ResponseSchema.parse(JSON.parse(jsonString)) + const validation = SpamFilterResponseSchema.parse(JSON.parse(jsonString)) console.log('Gemini Validation: isSpam=%s, confidenceScore=%s', validation.isSpam, validation.confidenceScore) diff --git a/nuxt-app/server/utils/authenticatedDirectus.ts b/nuxt-app/server/utils/authenticatedDirectus.ts index 4f9eac28..240a0dca 100644 --- a/nuxt-app/server/utils/authenticatedDirectus.ts +++ b/nuxt-app/server/utils/authenticatedDirectus.ts @@ -15,12 +15,15 @@ import { updateItem, updateItems, uploadFiles, + type QueryFilter, } from '@directus/sdk' import type { Collections } from '~/services/directus' import type { DirectusContentVersion, DirectusMeetupRegistrationItem, DirectusNewsletterSubscriberItem, + DirectusPodcastItem, + DirectusRatingItem, DirectusTicketDiscountCodeItem, DirectusTicketItem, DirectusTicketOrderItem, @@ -574,6 +577,30 @@ export function useAuthenticatedDirectus() { return (updated?.length ?? 0) > 0 } + // Latest rating of the podcast from the same anonymized, hashed IP created at or after `since`. + // Needs the token: the public role may create ratings but not read their `ip` or `target`. + async function getRecentRatingByIPHash(podcast: Pick, ipHash: string, since: Date) { + const ratings = await client.request( + readItems('ratings', { + filter: { + ip: { _eq: ipHash }, + date_created: { _gte: since.toISOString() }, + // `target` is a many-to-any relation, so the filter has to name the collection. + target: { 'target:podcasts': { id: { _eq: podcast.id } } }, + } as QueryFilter, + fields: ['id', 'up_or_down'], + sort: ['-date_created'], + limit: 1, + }) + ) + + return ratings?.[0] ?? null + } + + async function updateRatingVote(rating: { id: string }, vote: 'up' | 'down') { + return await client.request(updateItem('ratings', rating.id, { up_or_down: vote })) + } + return { getSpeakerByPortalToken, updateSpeaker, @@ -609,5 +636,7 @@ export function useAuthenticatedDirectus() { createMeetupRegistration, readMeetupRegistrationByCancelToken, cancelMeetupRegistration, + getRecentRatingByIPHash, + updateRatingVote, } } diff --git a/nuxt-app/server/utils/index.ts b/nuxt-app/server/utils/index.ts index c4adccba..57476e86 100644 --- a/nuxt-app/server/utils/index.ts +++ b/nuxt-app/server/utils/index.ts @@ -1,4 +1,6 @@ +export * from './aiSpamFilter' export * from './authenticatedDirectus' +export * from './ipProcessing' export * from './sendEmail' export * from './schema' export * from './stripe' diff --git a/nuxt-app/helpers/ipProcessing.ts b/nuxt-app/server/utils/ipProcessing.ts similarity index 88% rename from nuxt-app/helpers/ipProcessing.ts rename to nuxt-app/server/utils/ipProcessing.ts index 48d02791..5f04b805 100644 --- a/nuxt-app/helpers/ipProcessing.ts +++ b/nuxt-app/server/utils/ipProcessing.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto' +import { isIPv4, isIPv6 } from 'node:net' + /** * Expand a compressed IPv6 address to its full 8-group form. * Handles ::1, ::ffff:x.x.x.x, and other compressed forms. @@ -33,12 +36,8 @@ function expandIPv6(ip: string): string { /** * Validate, anonymize, and SHA-256 hash an IP address. * Returns hex string or undefined if the IP is invalid. - * Uses dynamic imports for node:crypto and node:net to avoid client bundle errors. */ -async function anonymizeAndHashIP(rawIP: string): Promise { - const { createHash } = await import('node:crypto') - const { isIPv4, isIPv6 } = await import('node:net') - +function anonymizeAndHashIP(rawIP: string): string | undefined { const trimmed = rawIP.trim() if (isIPv4(trimmed)) { diff --git a/nuxt-app/server/utils/schema.ts b/nuxt-app/server/utils/schema.ts index df4ab2f2..e425dae6 100644 --- a/nuxt-app/server/utils/schema.ts +++ b/nuxt-app/server/utils/schema.ts @@ -16,6 +16,13 @@ export const EmailSchema = z.object({ .max(2048, 'Deine Nachricht darf nicht länger als 2.048 Zeichen lang sein.'), }) +// The JSON the Gemini spam filter is asked to answer with +export const SpamFilterResponseSchema = z.object({ + confidenceScore: z.number().min(0).max(1), + isSpam: z.boolean(), + reason: z.string(), +}) + // Podcast vote (rating) export const VoteSchema = z.object({ diff --git a/nuxt-app/test/ipProcessing.test.ts b/nuxt-app/test/ipProcessing.test.ts new file mode 100644 index 00000000..2f2ece5a --- /dev/null +++ b/nuxt-app/test/ipProcessing.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { anonymizeAndHashIP } from '../server/utils/ipProcessing' + +// Stored ratings carry these hashes, and the vote cooldown matches on them. Any change to the +// anonymization or hashing splits the cooldown across a deploy, so these values must not move. +describe('anonymizeAndHashIP', () => { + it.each([ + ['203.0.113.7', '2172243fafcf160d8b3bf50e372877cfd3700fe0f59db1c795e26b2201cdb65f'], + ['198.51.100.1', 'a1f1df59d2c345dbc1f874828f8e38d51d4f204b292a49476339d0cbe2c1cb8f'], + ['2001:db8:85a3::8a2e:370:7334', 'dab6247d103932741ae6fd88d67d510316f0e0334b117cf2c47cd95ab2b8514a'], + ['2001:db8::1', '45095fc532f8e21e39470bf8f3e0e4f7cfbcb77d47cdde8b75a8daa7aa0e7c6b'], + ['::1', '3078dd4fcbe4e59f28e7bb376037df6186a78cb47515f95812dcde2fd46116ef'], + ])('hashes %s to its stored value', (ip, hash) => { + expect(anonymizeAndHashIP(ip)).toBe(hash) + }) + + it('drops the last two IPv4 octets before hashing', () => { + expect(anonymizeAndHashIP('203.0.113.250')).toBe(anonymizeAndHashIP('203.0.113.7')) + }) + + it('treats the compressed and the full IPv6 form alike', () => { + expect(anonymizeAndHashIP('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe( + anonymizeAndHashIP('2001:db8:85a3::8a2e:370:7334') + ) + }) + + it('ignores surrounding whitespace', () => { + expect(anonymizeAndHashIP(' 203.0.113.7 ')).toBe(anonymizeAndHashIP('203.0.113.7')) + }) + + it.each(['not-an-ip', '', '999.1.1.1'])('returns undefined for %j', (ip) => { + expect(anonymizeAndHashIP(ip)).toBeUndefined() + }) +}) diff --git a/nuxt-app/test/vote.route.test.ts b/nuxt-app/test/vote.route.test.ts index 62407c9d..b6c85cfe 100644 --- a/nuxt-app/test/vote.route.test.ts +++ b/nuxt-app/test/vote.route.test.ts @@ -4,25 +4,32 @@ import voteHandler from '../server/api/vote.post' // Nuxt/Nitro auto-imports must exist before the handler is imported (its // `defineEventHandler(...)` runs at load time); vi.hoisted runs first. -const directus = vi.hoisted(() => { +const { directus, authenticated } = vi.hoisted(() => { const g = globalThis as any g.defineEventHandler = (fn: any) => fn g.readBody = async (event: any) => event.body ?? {} g.createError = (input: any) => Object.assign(new Error(input.message), input) return { - getPodcastBySlug: vi.fn(), - getRecentRatingByIP: vi.fn(), - updateRatingVote: vi.fn(), - createRating: vi.fn(), + // The public client: the CMS's public role may read podcasts and create ratings. + directus: { getPodcastBySlug: vi.fn(), createRating: vi.fn() }, + // The token client: the public role may not read a rating's `ip` or `target`, nor update one. + authenticated: { getRecentRatingByIPHash: vi.fn(), updateRatingVote: vi.fn() }, } }) vi.mock('~/composables/useDirectus', () => ({ useDirectus: () => directus })) +vi.mock('../server/utils', async (importOriginal) => ({ + ...(await importOriginal()), + useAuthenticatedDirectus: () => authenticated, +})) vi.mock('~/config', () => import('../config')) const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' const PODCAST = { id: 'podcast-1', slug: 'folge-1' } +// anonymizeAndHashIP('203.0.113.7'): the SHA-256 of '203.0.0.0', the address with its last two octets +// zeroed. Stored ratings carry this value, so a change here would split the cooldown across a deploy. +const IP_HASH = '2172243fafcf160d8b3bf50e372877cfd3700fe0f59db1c795e26b2201cdb65f' const vote = (direction: 'up' | 'down', headers: Record = { 'user-agent': BROWSER_UA }) => (voteHandler as any)({ @@ -31,9 +38,9 @@ const vote = (direction: 'up' | 'down', headers: Record { - Object.values(directus).forEach((fn) => fn.mockReset()) + for (const fn of [...Object.values(directus), ...Object.values(authenticated)]) fn.mockReset() directus.getPodcastBySlug.mockResolvedValue(PODCAST) - directus.getRecentRatingByIP.mockResolvedValue(null) + authenticated.getRecentRatingByIPHash.mockResolvedValue(null) directus.createRating.mockResolvedValue({ id: 'rating-new' }) }) @@ -45,11 +52,19 @@ describe('POST /api/vote', () => { it('stores a vote from a browser', async () => { await expect(vote('up')).resolves.toEqual({ id: 'rating-new' }) expect(directus.createRating).toHaveBeenCalledWith('up', PODCAST, { - ip: '203.0.113.7', + ip: IP_HASH, user_agent: BROWSER_UA, }) }) + it('stores a vote without an IP, and skips the cooldown, when the IP is invalid', async () => { + await expect(vote('up', { 'user-agent': BROWSER_UA, 'x-forwarded-for': 'not-an-ip' })).resolves.toEqual({ + id: 'rating-new', + }) + expect(authenticated.getRecentRatingByIPHash).not.toHaveBeenCalled() + expect(directus.createRating).toHaveBeenCalledWith('up', PODCAST, { user_agent: BROWSER_UA }) + }) + it.each([ 'meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)', 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', @@ -61,42 +76,42 @@ describe('POST /api/vote', () => { expect(directus.createRating).not.toHaveBeenCalled() }) - it('looks for an earlier vote from the same IP within the cooldown', async () => { + it('looks for an earlier vote from the same IP hash within the cooldown', async () => { const now = Date.parse('2026-10-20T08:00:00Z') vi.spyOn(Date, 'now').mockReturnValue(now) await vote('up') - expect(directus.getRecentRatingByIP).toHaveBeenCalledWith( + expect(authenticated.getRecentRatingByIPHash).toHaveBeenCalledWith( PODCAST, - '203.0.113.7', + IP_HASH, new Date(now - VOTE_COOLDOWN_MINUTES * 60 * 1000) ) }) it('replaces an earlier opposite vote within the cooldown instead of adding one', async () => { - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('down')).resolves.toEqual({ id: 'rating-old' }) - expect(directus.updateRatingVote).toHaveBeenCalledWith({ id: 'rating-old', up_or_down: 'up' }, 'down') + expect(authenticated.updateRatingVote).toHaveBeenCalledWith({ id: 'rating-old', up_or_down: 'up' }, 'down') expect(directus.createRating).not.toHaveBeenCalled() }) it('counts a repeated vote within the cooldown once', async () => { - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) await expect(vote('up')).resolves.toEqual({ id: 'rating-old' }) - expect(directus.updateRatingVote).not.toHaveBeenCalled() + expect(authenticated.updateRatingVote).not.toHaveBeenCalled() expect(directus.createRating).not.toHaveBeenCalled() }) it('answers 500 when the earlier vote cannot be replaced', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIP.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) - directus.updateRatingVote.mockRejectedValue(new Error('forbidden')) + authenticated.getRecentRatingByIPHash.mockResolvedValue({ id: 'rating-old', up_or_down: 'up' }) + authenticated.updateRatingVote.mockRejectedValue(new Error('forbidden')) await expect(vote('down')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() }) it('answers 500 when the lookup fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - directus.getRecentRatingByIP.mockRejectedValue(new Error('network')) + authenticated.getRecentRatingByIPHash.mockRejectedValue(new Error('network')) await expect(vote('up')).rejects.toMatchObject({ statusCode: 500 }) expect(directus.createRating).not.toHaveBeenCalled() })