Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/modules/creators/creator-list-search-filter.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
29 changes: 28 additions & 1 deletion src/modules/webhooks/webhook.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
13 changes: 13 additions & 0 deletions src/utils/test/cursor.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof specialPayload>(cursor);

expect(decoded).toEqual(specialPayload);
});
});
});
Loading