From c1ef27477365174a738152a8cbdb9c69513b4e02 Mon Sep 17 00:00:00 2001 From: ABAYOMI CORNELIUS Date: Fri, 24 Jul 2026 15:59:43 +0100 Subject: [PATCH] test: cursor round-trip edge case, creator search integration test, webhook URL validation (closes #610, #613, #614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #614 — cursor.utils.test.ts already comprehensively covered round-trip correctness and every malformed/tampered/empty-input case via CursorChecksumError assertions. Added the one gap against #614's acceptance criteria: a payload with special characters (spaces, slashes, unicode). Note: #614 describes decodeCursor as "returning null" for invalid input, but the actual (and better) design throws a typed CursorChecksumError, already exercised by 7 existing tests in this file — kept that real behavior rather than asserting a null-return that doesn't match the implementation. #610 — Added creator-list-search-filter.integration.test.ts following the existing real-DB pattern used by sibling tests in this module (e.g. creator-list-price-filter.integration.test.ts): seeds three creators (Alpha, AlphaTwo, Beta) via Prisma and asserts against the real GET /api/v1/creators?search= endpoint — partial match returns both Alpha and AlphaTwo while excluding Beta, matching is case-insensitive, and an empty search string returns all seeded creators. #613 — Found that CreateWebhookSchema only checked z.string().url(), which accepts plain-HTTP and hostless URLs (verified: 'http://example.com' and 'https://' both passed before this change). Added two refinements requiring https: protocol and a non-empty hostname, each with its own field-level error message. Added webhook-registration-url-validation.integration.test.ts covering: http:// rejected 400, no-host https:// rejected 400, non-URL string rejected 400, valid HTTPS accepted 201, and field-level error message assertion via res.body.error.details[].field. Confirmed no existing test breaks — the one place callback_url is ever a plain http:// value (webhook.integration.test.ts's retry test) writes directly via prisma.webhook.create, bypassing this schema entirely. Verification note: could not run the two new integration tests against a real Postgres in this environment (no Docker, no local Postgres, and completing a Homebrew install would have required a system-directory chown I didn't want to do unprompted). Verified via `tsc --noEmit` (clean) and `eslint` (clean) instead, confirmed both new test files fail with a DB-connectivity error (not a type/logic error) when run without a database, and confirmed the webhook schema change's exact accept/reject behavior with a standalone Node script exercising the zod schema directly. --- ...tor-list-search-filter.integration.test.ts | 115 +++++++++++++++++ ...tration-url-validation.integration.test.ts | 122 ++++++++++++++++++ src/modules/webhooks/webhook.schemas.ts | 29 ++++- src/utils/test/cursor.utils.test.ts | 13 ++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/modules/creators/creator-list-search-filter.integration.test.ts create mode 100644 src/modules/webhooks/webhook-registration-url-validation.integration.test.ts diff --git a/src/modules/creators/creator-list-search-filter.integration.test.ts b/src/modules/creators/creator-list-search-filter.integration.test.ts new file mode 100644 index 0000000..eac9538 --- /dev/null +++ b/src/modules/creators/creator-list-search-filter.integration.test.ts @@ -0,0 +1,115 @@ +// src/modules/creators/creator-list-search-filter.integration.test.ts +// Integration tests for #610 — partial, case-insensitive name search on the +// creator list endpoint. + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; + +const USER_IDS = [ + 'search-filter-user-alpha', + 'search-filter-user-alphatwo', + 'search-filter-user-beta', +]; +const HANDLES = [ + 'search-filter-alpha', + 'search-filter-alphatwo', + 'search-filter-beta', +]; +const DISPLAY_NAMES = ['Alpha', 'AlphaTwo', 'Beta']; + +describe('#610 creator list search (partial name match)', () => { + let creatorIds: string[]; + + beforeAll(async () => { + creatorIds = []; + + for (let i = 0; i < 3; i++) { + await prisma.user.upsert({ + where: { id: USER_IDS[i] }, + create: { + id: USER_IDS[i], + email: `search-filter-${i}@example.test`, + passwordHash: 'dummy-hash', + firstName: 'Search', + lastName: `Filter ${i}`, + }, + update: {}, + }); + + const creator = await prisma.creatorProfile.upsert({ + where: { userId: USER_IDS[i] }, + create: { + userId: USER_IDS[i], + handle: HANDLES[i], + displayName: DISPLAY_NAMES[i], + }, + update: { displayName: DISPLAY_NAMES[i] }, + }); + + creatorIds.push(creator.id); + } + }); + + afterAll(async () => { + await prisma.creatorProfile.deleteMany({ + where: { handle: { in: HANDLES } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: USER_IDS } }, + }); + await prisma.$disconnect(); + }); + + it('returns both Alpha and AlphaTwo, and excludes Beta, for a case-insensitive partial match', async () => { + const res = await supertest(app).get('/api/v1/creators?search=alpha'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + expect(ids).toContain(creatorIds[0]); // Alpha + expect(ids).toContain(creatorIds[1]); // AlphaTwo + expect(ids).not.toContain(creatorIds[2]); // Beta + }); + + it('matches regardless of search term casing', async () => { + const res = await supertest(app).get('/api/v1/creators?search=ALPHA'); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + expect(ids).toContain(creatorIds[0]); + expect(ids).toContain(creatorIds[1]); + expect(ids).not.toContain(creatorIds[2]); + }); + + it('returns all seeded creators when the search string is empty', async () => { + const res = await supertest(app).get('/api/v1/creators?search='); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + expect(ids).toContain(creatorIds[0]); + expect(ids).toContain(creatorIds[1]); + expect(ids).toContain(creatorIds[2]); + }); + + it('returns no seeded creators for a search term that matches none of them', async () => { + const res = await supertest(app).get( + '/api/v1/creators?search=zzz-no-match-zzz' + ); + expect(res.status).toBe(200); + + const ids = (res.body.data.items as any[]) + .filter((c: any) => creatorIds.includes(c.id)) + .map((c: any) => c.id); + + expect(ids).toHaveLength(0); + }); +}); diff --git a/src/modules/webhooks/webhook-registration-url-validation.integration.test.ts b/src/modules/webhooks/webhook-registration-url-validation.integration.test.ts new file mode 100644 index 0000000..e8e434c --- /dev/null +++ b/src/modules/webhooks/webhook-registration-url-validation.integration.test.ts @@ -0,0 +1,122 @@ +// src/modules/webhooks/webhook-registration-url-validation.integration.test.ts +// Integration tests for #613 — webhook registration rejects invalid callback URLs. +// +// Found while scoping this issue: CreateWebhookSchema previously only checked +// `z.string().url()`, which accepts plain-HTTP and hostless URLs. Strengthened +// in webhook.schemas.ts to require https:// and a non-empty host — these tests +// cover both the pre-existing "not a URL at all" case and the two new checks. + +import supertest from 'supertest'; +import app from '../../app'; +import { prisma } from '../../utils/prisma.utils'; +import { Keypair } from '@stellar/stellar-base'; +import { createHash } from 'crypto'; + +const keypair = Keypair.random(); +const walletAddress = keypair.publicKey(); +const userId = 'webhook-url-validation-user'; +const creatorId = 'webhook-url-validation-creator'; + +function authHeaders(method: string, path: string, cId: string) { + const timestamp = Date.now().toString(); + const payload = `${method.toUpperCase()}:${path}:${cId}:${timestamp}`; + const hash = createHash('sha256').update(payload, 'utf8').digest(); + const signature = keypair.sign(hash).toString('base64'); + return { + 'x-wallet-address': walletAddress, + 'x-signature': signature, + 'x-timestamp': timestamp, + }; +} + +beforeAll(async () => { + await prisma.user.create({ + data: { + id: userId, + email: 'webhook-url-validation@example.test', + passwordHash: 'dummy-hash', + firstName: 'UrlValidation', + lastName: 'Test', + }, + }); + + await prisma.stellarWallet.create({ + data: { address: walletAddress, userId }, + }); + + await prisma.creatorProfile.create({ + data: { + id: creatorId, + userId, + handle: 'webhook-url-validation-creator', + displayName: 'Webhook URL Validation Creator', + }, + }); +}); + +afterAll(async () => { + await prisma.webhookEvent.deleteMany({ where: { webhook: { creatorId } } }); + await prisma.webhook.deleteMany({ where: { creatorId } }); + await prisma.creatorProfile.delete({ where: { id: creatorId } }).catch(() => {}); + await prisma.stellarWallet + .delete({ where: { address: walletAddress } }) + .catch(() => {}); + await prisma.user.delete({ where: { id: userId } }).catch(() => {}); + await prisma.$disconnect(); +}); + +describe('#613 POST /api/v1/creators/:id/webhooks — callback_url validation', () => { + const basePath = `/api/v1/creators/${creatorId}/webhooks`; + + it('rejects an http:// (non-HTTPS) URL with 400 and a field-level message', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'http://example.com/hook', events: ['buy'] }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + const fields = res.body.error.details.map((d: any) => d.field); + expect(fields).toContain('callback_url'); + }); + + it('rejects a URL with no host with 400', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'https://', events: ['buy'] }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + const fields = res.body.error.details.map((d: any) => d.field); + expect(fields).toContain('callback_url'); + }); + + it('rejects a plain string that is not a URL with 400', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ callback_url: 'not-a-url-at-all', events: ['buy'] }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + const fields = res.body.error.details.map((d: any) => d.field); + expect(fields).toContain('callback_url'); + }); + + it('accepts a valid HTTPS URL and returns 201', async () => { + const res = await supertest(app) + .post(basePath) + .set(authHeaders('POST', basePath, creatorId)) + .send({ + callback_url: 'https://example.com/url-validation-hook', + events: ['buy'], + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.callbackUrl).toBe( + 'https://example.com/url-validation-hook' + ); + }); +}); diff --git a/src/modules/webhooks/webhook.schemas.ts b/src/modules/webhooks/webhook.schemas.ts index c761ed9..f83085a 100644 --- a/src/modules/webhooks/webhook.schemas.ts +++ b/src/modules/webhooks/webhook.schemas.ts @@ -2,8 +2,35 @@ import { z } from 'zod'; export const WebhookEventEnum = z.enum(['buy', 'sell']); +/** + * Callback URLs must be well-formed, HTTPS, and have a non-empty host — + * webhook payloads carry trade data and are delivered over the network, so + * plain-HTTP or hostless callback URLs are rejected outright (#613). + */ export const CreateWebhookSchema = z.object({ - callback_url: z.string().url('callback_url must be a valid URL'), + callback_url: z + .string() + .url('callback_url must be a valid URL') + .refine( + value => { + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } + }, + { message: 'callback_url must use https://' } + ) + .refine( + value => { + try { + return new URL(value).hostname.length > 0; + } catch { + return false; + } + }, + { message: 'callback_url must include a host' } + ), events: z .array(WebhookEventEnum, { required_error: 'events is required' }) .min(1, 'At least one event type is required'), diff --git a/src/utils/test/cursor.utils.test.ts b/src/utils/test/cursor.utils.test.ts index aef276d..96ad23d 100644 --- a/src/utils/test/cursor.utils.test.ts +++ b/src/utils/test/cursor.utils.test.ts @@ -113,5 +113,18 @@ describe('Cursor Utils', () => { expect(() => decodeCursor(cursor)).toThrow(CursorChecksumError); }); + + it('should round-trip a payload containing special characters (spaces, slashes, unicode)', () => { + const specialPayload = { + id: 'user 123/456', + createdAt: '2023-01-01T00:00:00.000Z', + note: 'a/b c?d&e=f café 名前', + }; + + const cursor = encodeCursor(specialPayload); + const decoded = decodeCursor(cursor); + + expect(decoded).toEqual(specialPayload); + }); }); });