Skip to content
Open

V1.23.0 #2192

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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dao-frontend",
"version": "1.22.3",
"version": "1.23.0",
"scripts": {
"prepare": "husky",
"dev": "NODE_OPTIONS='--enable-source-maps' next dev | pino-pretty",
Expand Down
218 changes: 218 additions & 0 deletions src/app/api/support/ticket/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}))

const EXPLORER = 'https://explorer.testnet.rootstock.io'
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const WEBHOOK_URL = 'https://hooks.slack.com/services/T000/B000/xxx'

const ADDRESS = '0x1234567890abcdefABCDEF1234567890abcdef12'
const TX_HASH = `0x${'a'.repeat(64)}`

const validBody = (overrides: Record<string, unknown> = {}) => ({
token: 'turnstile-token',
topic: 'Staking',
referenceType: 'Wallet address',
reference: ADDRESS,
description: 'My stake is not showing up in the dashboard.',
...overrides,
})

const createRequest = (body: unknown): NextRequest =>
new NextRequest('http://localhost/api/support/ticket', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})

/** Imports the route fresh so module-level env reads (EXPLORER_URL) are re-evaluated. */
const importRoute = async () => (await import('./route')).POST

interface SlackPayload {
text: string
blocks: { type: string; fields?: { type: string; text: string }[] }[]
}

/** Reads the JSON body of the nth `fetch` call the route made. */
const fetchBody = <T>(mock: ReturnType<typeof vi.fn>, callIndex: number): T =>
JSON.parse(mock.mock.calls[callIndex][1].body as string) as T

const slackFields = (payload: SlackPayload): string[] =>
payload.blocks.flatMap(block => block.fields?.map(field => field.text) ?? [])

let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
vi.resetModules()
vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret')
vi.stubEnv('SLACK_SUPPORT_WEBHOOK_URL', WEBHOOK_URL)
vi.stubEnv('NEXT_PUBLIC_EXPLORER', EXPLORER)

fetchMock = vi.fn(async (url: string) => {
if (url === SITEVERIFY_URL) {
return new Response(JSON.stringify({ success: true }), { status: 200 })
}
return new Response('ok', { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})

describe('POST /api/support/ticket', () => {
describe('reference validation', () => {
it('accepts a well-formed address and returns a ticket ref', async () => {
const POST = await importRoute()

const response = await POST(createRequest(validBody()))
const data = await response.json()

expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.ticket).toMatch(/^SUP-[0-9A-F]{8}$/)
})

it('accepts a well-formed tx hash', async () => {
const POST = await importRoute()

const response = await POST(
createRequest(validBody({ referenceType: 'Transaction hash', reference: TX_HASH })),
)

expect(response.status).toBe(200)
})

it.each([
['a missing reference', { reference: undefined }],
['an empty reference', { reference: '' }],
['a malformed address', { reference: '0xnothex' }],
['a tx hash sent as an address', { reference: TX_HASH }],
['an address sent as a tx hash', { referenceType: 'Transaction hash', reference: ADDRESS }],
])('rejects %s with invalid_reference', async (_label, overrides) => {
const POST = await importRoute()

const response = await POST(createRequest(validBody(overrides)))
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error).toBe('invalid_reference')
})

it.each([
['a missing type', undefined],
['an unknown type', 'ENS name'],
['an empty type', ''],
])('rejects %s with invalid_reference_type', async (_label, referenceType) => {
const POST = await importRoute()

const response = await POST(createRequest(validBody({ referenceType })))
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error).toBe('invalid_reference_type')
})

it('reports the higher-priority field first when several are invalid', async () => {
const POST = await importRoute()

const response = await POST(createRequest(validBody({ topic: 'Nope', reference: 'bad' })))
const data = await response.json()

expect(data.error).toBe('invalid_topic')
})

it('never reaches Slack when the reference is invalid', async () => {
const POST = await importRoute()

await POST(createRequest(validBody({ reference: 'bad' })))

expect(fetchMock).not.toHaveBeenCalled()
})
})

describe('Slack payload', () => {
it('links the reference to the explorer under the selected type', async () => {
const POST = await importRoute()

await POST(createRequest(validBody({ referenceType: 'Transaction hash', reference: TX_HASH })))
const payload = fetchBody<SlackPayload>(fetchMock, 1)

expect(slackFields(payload)).toContain(`*Transaction hash:*\n<${EXPLORER}/tx/${TX_HASH}|${TX_HASH}>`)
})

it('uses the address path for the address type', async () => {
const POST = await importRoute()

await POST(createRequest(validBody()))
const payload = fetchBody<SlackPayload>(fetchMock, 1)

expect(slackFields(payload)).toContain(`*Wallet address:*\n<${EXPLORER}/address/${ADDRESS}|${ADDRESS}>`)
})

it('normalizes a trailing slash on the explorer URL', async () => {
vi.stubEnv('NEXT_PUBLIC_EXPLORER', `${EXPLORER}//`)
const POST = await importRoute()

await POST(createRequest(validBody()))
const payload = fetchBody<SlackPayload>(fetchMock, 1)

expect(slackFields(payload).join('\n')).toContain(`<${EXPLORER}/address/${ADDRESS}|`)
})

it('falls back to the bare reference when no explorer is configured', async () => {
vi.stubEnv('NEXT_PUBLIC_EXPLORER', '')
const POST = await importRoute()

await POST(createRequest(validBody()))
const payload = fetchBody<SlackPayload>(fetchMock, 1)

expect(slackFields(payload)).toContain(`*Wallet address:*\n${ADDRESS}`)
})

it('includes the reference in the notification fallback text', async () => {
const POST = await importRoute()

await POST(createRequest(validBody()))
const payload = fetchBody<SlackPayload>(fetchMock, 1)

expect(payload.text).toContain(`Wallet address: ${ADDRESS}`)
})
})

describe('captcha and delivery', () => {
it('does not post to Slack when Turnstile rejects the token', async () => {
fetchMock.mockImplementation(
async () => new Response(JSON.stringify({ success: false }), { status: 200 }),
)
const POST = await importRoute()

const response = await POST(createRequest(validBody()))
const data = await response.json()

expect(response.status).toBe(403)
expect(data.error).toBe('captcha_failed')
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('returns delivery_failed when the webhook responds non-2xx', async () => {
fetchMock.mockImplementation(async (url: string) =>
url === SITEVERIFY_URL
? new Response(JSON.stringify({ success: true }), { status: 200 })
: new Response('no_service', { status: 404 }),
)
const POST = await importRoute()

const response = await POST(createRequest(validBody()))
const data = await response.json()

expect(response.status).toBe(502)
expect(data.error).toBe('delivery_failed')
})
})
})
69 changes: 55 additions & 14 deletions src/app/api/support/ticket/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

import { EXPLORER_URL } from '@/lib/constants'
import { logger } from '@/lib/logger'
import { SUPPORT_TOPICS } from '@/shared/constants'
import {
isValidSupportReference,
SUPPORT_REFERENCE_TYPES,
SUPPORT_TOPICS,
type SupportReferenceType,
} from '@/shared/constants'

const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const ROUTE = '/api/support/ticket'
Expand All @@ -22,26 +28,43 @@ interface SiteVerifyResponse {

// Validates and types the untrusted request body in one step. Empty/omitted
// email normalizes to `undefined`.
const ticketSchema = z.object({
token: z.string().trim().min(1),
topic: z.enum(SUPPORT_TOPICS),
description: z.string().trim().min(MIN_DESCRIPTION_LENGTH).max(MAX_DESCRIPTION_LENGTH),
email: z
.union([z.literal(''), z.string().trim().email().max(MAX_EMAIL_LENGTH)])
.optional()
.transform(value => value || undefined),
})
const ticketSchema = z
.object({
token: z.string().trim().min(1),
topic: z.enum(SUPPORT_TOPICS),
referenceType: z.enum(SUPPORT_REFERENCE_TYPES),
reference: z.string().trim().min(1),
description: z.string().trim().min(MIN_DESCRIPTION_LENGTH).max(MAX_DESCRIPTION_LENGTH),
email: z
.union([z.literal(''), z.string().trim().email().max(MAX_EMAIL_LENGTH)])
.optional()
.transform(value => value || undefined),
})
.superRefine(({ referenceType, reference }, ctx) => {
if (!isValidSupportReference(referenceType, reference)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['reference'], message: 'invalid_reference' })
}
})

type TicketData = z.infer<typeof ticketSchema>

// Maps the first failing field to the error code the client already understands.
const FIELD_ERROR_CODES: Record<keyof TicketData, string> = {
token: 'missing_token',
topic: 'invalid_topic',
referenceType: 'invalid_reference_type',
reference: 'invalid_reference',
description: 'invalid_description',
email: 'invalid_email',
}
const FIELD_PRIORITY: (keyof TicketData)[] = ['token', 'topic', 'description', 'email']
const FIELD_PRIORITY: (keyof TicketData)[] = [
'token',
'topic',
'referenceType',
'reference',
'description',
'email',
]

/**
* Strips Unicode control characters a reader can't see but that change how
Expand All @@ -65,9 +88,18 @@ const escapeSlackMrkdwn = (text: string): string =>
const generateTicketRef = (): string =>
`SUP-${crypto.randomUUID().replaceAll('-', '').slice(0, 8).toUpperCase()}`

const formatReference = (referenceType: SupportReferenceType, reference: string): string => {
const label = escapeSlackMrkdwn(reference)
if (!EXPLORER_URL) return label
const path = referenceType === 'Transaction hash' ? 'tx' : 'address'
return `<${EXPLORER_URL}/${path}/${encodeURIComponent(reference)}|${label}>`
}

const buildSlackBlocks = (
ticketRef: string,
topic: string,
referenceType: SupportReferenceType,
reference: string,
description: string,
email: string | undefined,
receivedAt: string,
Expand All @@ -92,6 +124,7 @@ const buildSlackBlocks = (
{ type: 'mrkdwn', text: `*Topic:*\n${topic}` },
{ type: 'mrkdwn', text: `*From:*\n${email ? escapeSlackMrkdwn(email) : '_anonymous_'}` },
{ type: 'mrkdwn', text: `*Received:*\n${receivedAt}` },
{ type: 'mrkdwn', text: `*${referenceType}:*\n${formatReference(referenceType, reference)}` },
],
},
{ type: 'divider' },
Expand Down Expand Up @@ -140,7 +173,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, error }, { status: 400 })
}

const { token, topic, description, email } = parsed.data
const { token, topic, referenceType, reference, description, email } = parsed.data

try {
// `remoteip` is intentionally omitted — the only header we could source it
Expand Down Expand Up @@ -169,8 +202,16 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
text: `New support ticket ${ticketRef} (${topic}) from ${email ? escapeSlackMrkdwn(email) : 'anonymous'}`,
blocks: buildSlackBlocks(ticketRef, topic, description, email, new Date().toISOString()),
text: `New support ticket ${ticketRef} (${topic}) from ${email ? escapeSlackMrkdwn(email) : 'anonymous'} — ${referenceType}: ${escapeSlackMrkdwn(reference)}`,
blocks: buildSlackBlocks(
ticketRef,
topic,
referenceType,
reference,
description,
email,
new Date().toISOString(),
),
}),
})

Expand Down
11 changes: 9 additions & 2 deletions src/app/backing/components/BuilderHeader/BuilderHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Link from 'next/link'
import { ComponentProps } from 'react'
import { Address } from 'viem'

import { IpfsAvatar } from '@/components/IpfsAvatar'
import { BuilderIcon } from '@/components/BuilderIcon'
import { Header } from '@/components/Typography'
import { cn, shortAddress, truncate } from '@/lib/utils'

Expand Down Expand Up @@ -37,7 +37,14 @@ export const BuilderHeader = ({
data-testid="builderHeaderContainer"
>
<div data-testid="builderAvatar">
<IpfsAvatar imageIpfs={imageIpfs} address={address} name={name || shortedAddress} size={88} />
<BuilderIcon
imageIpfs={imageIpfs}
address={address}
name={name || shortedAddress}
size={88}
fallbackClassName="bg-v3-text-100"
fallbackValue={address.toLowerCase()}
/>
</div>
<Header
className="mt-2 text-center text-v3-primary"
Expand Down
Loading
Loading