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); + }); }); });