Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
NEXT_PUBLIC_MAINNET_RPC_URL=
NEXT_PUBLIC_DEVNET_RPC_URL=
NEXT_PUBLIC_TESTNET_RPC_URL=
# Configuration for "metadata" service. set "ENABLED" to true to use it
NEXT_PUBLIC_METADATA_ENABLED=false
NEXT_PUBLIC_METADATA_TIMEOUT=
NEXT_PUBLIC_METADATA_MAX_CONTENT_SIZE=
NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer"
105 changes: 105 additions & 0 deletions app/api/metadata/proxy/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @jest-environment node
*/
import _dns from 'dns';
import fetch, { Headers } from 'node-fetch';

import { GET } from '../route';

const dns = _dns.promises;

function setEnvironment(key: string, value: string) {
Object.assign(process.env, { ...process.env, [key]: value });
}

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

jest.mock('dns', () => {
const originalDns = jest.requireActual('dns');
const lookupFn = jest.fn();
return {
...originalDns,
promises: {
...originalDns.promises,
lookup: lookupFn,
}
};
});

async function mockFileResponseOnce(data: any, headers: Headers){
// @ts-expect-error unavailable mock method for fetch
fetch.mockResolvedValueOnce({ headers, json: async () => data });
}

const ORIGIN = 'http://explorer.solana.com';

function requestFactory(uri?: string) {
const params = new URLSearchParams({ uri: uri ?? '' });
const request = new Request(`${ORIGIN}/api/metadata/devnet?${params.toString()}`);
const nextParams = { params: { network: 'devnet' } };

return { nextParams, request };
}

describe('metadata/[network] endpoint', () => {
const validUrl = encodeURIComponent('http://external.resource/file.json');
const unsupportedUri = encodeURIComponent('ftp://unsupported.resource/file.json');

afterEach(() => {
jest.clearAllMocks();
})

it('should return status when disabled', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'false');

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(404);
});

it('should return 400 for URIs with unsupported protocols', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true');

const request = requestFactory(unsupportedUri);
const response = await GET(request.request, request.nextParams);
expect(response.status).toBe(400);
});

it('should return proper status upon processig data', async () => {
setEnvironment('NEXT_PUBLIC_METADATA_ENABLED', 'true')

const { request, nextParams } = requestFactory();
const response = await GET(request, nextParams);
expect(response.status).toBe(400);

// fail on encoded incorrectly input
const request2 = requestFactory('https://example.com/%E0%A4%A');
expect((await GET(request2.request, request2.nextParams)).status).toBe(400);

// fail due to unexpected error
const request3 = requestFactory(validUrl);
const result = await GET(request3.request, request3.nextParams);
expect(result.status).toBe(403);
});

it('should handle valid response successfully', async () => {
await mockFileResponseOnce({ attributes: [], name: "NFT" }, new Headers({
'Cache-Control': 'no-cache',
'Content-Length': '140',
'Content-Type': 'application/json',
'Etag': 'random-etag',
}));
// @ts-expect-error lookup does not have mocked fn
dns.lookup.mockResolvedValueOnce([{ address: '8.8.8.8' }]);

const request = requestFactory(validUrl);
expect((await GET(request.request, request.nextParams)).status).toBe(200);
})
});
118 changes: 118 additions & 0 deletions app/api/metadata/proxy/__tests__/fetch-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @jest-environment node
*/
import fetch, { Headers } from 'node-fetch';

import { fetchResource } from '../feature';

jest.mock('node-fetch', () => {
const originalFetch = jest.requireActual('node-fetch')
const mockFn = jest.fn();

Object.assign(mockFn, originalFetch);

return mockFn
});

/**
* mock valid response
*/
function mockFetchOnce(data: any = {}, headers: Headers = new Headers()) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers,
json: async () => data
});
}

/**
* mock error during process
*/
function mockRejectOnce<T extends Error>(error: T) {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce(error);
}

describe('fetchResource', () => {
const uri = 'http://hello.world/data.json' ;
const headers = new Headers({ 'Content-Type': 'application/json' });

afterEach(() => {
jest.clearAllMocks();
})

it('should be called with proper arguments', async () => {
mockFetchOnce({}, new Headers({ 'Content-Type': 'application/json, charset=utf-8' }));

const resource = await fetchResource(uri, headers, 100, 100);

expect(fetch).toHaveBeenCalledWith(uri, expect.anything());
expect(resource.data).toEqual({});
})

it('should throw exception for unsupported media', async () => {
mockFetchOnce();

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Unsupported Media Type');
})

it('should throw exception upon exceeded size', async () => {
mockRejectOnce(new Error('FetchError: content size at https://path/to/resour.ce over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded');
})

it('should handle AbortSignal', async () => {
class TimeoutError extends Error {
constructor() {
super()
this.name = 'TimeoutError'
}
}
mockRejectOnce(new TimeoutError());

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Gateway Timeout')
})

it('should handle size overflow', async () => {
mockRejectOnce(new Error('file is over limit: 100'));

expect(() => {
return fetchResource(uri, headers, 100, 100);
}).rejects.toThrowError('Max Content Size Exceeded')
})

it('should handle unexpected result', async () => {
// @ts-expect-error fetch does not have mocked fn
fetch.mockRejectedValueOnce({ data: "unexpected exception" });

const fn = () => {
return fetchResource(uri, headers, 100, 100);
}

try {
await fn();
} catch(e: any) {
expect(e.message).toEqual('General Error')
expect(e.status).toEqual(500)
}
})

it('should handle malformed JSON response gracefully', async () => {
// Mock fetch to return a response with invalid JSON
// @ts-expect-error fetch does not have mocked fn
fetch.mockResolvedValueOnce({
headers: new Headers({ 'Content-Type': 'application/json' }),
// Simulate malformed JSON by rejecting during json parsing
json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') }
});

await expect(fetchResource(uri, headers, 100, 100)).rejects.toThrowError('Unsupported Media Type');
});
})
84 changes: 84 additions & 0 deletions app/api/metadata/proxy/__tests__/ip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @jest-environment node
*/
import _dns from 'dns';

import { checkURLForPrivateIP } from '../feature/ip';

const dns = _dns.promises;

jest.mock('dns', () => {
const originalDns = jest.requireActual('dns');
const lookupFn = jest.fn();
return {
...originalDns,
promises: {
...originalDns.promises,
lookup: lookupFn,
}
};
});

/**
* mock valid response
*/
function mockLookupOnce(addresses: { address: string }[]) {
// @ts-expect-error lookup does not have mocked fn
dns.lookup.mockResolvedValueOnce(addresses);
}

describe('ip::checkURLForPrivateIP', () => {
afterEach(() => {
jest.clearAllMocks();
});

// do not throw exceptions forinvalid input to not break the execution flow
test('should handle invalid URL gracefully', async () => {
await expect(checkURLForPrivateIP('not-a-valid-url')).resolves.toBe(true);
});

test('should block unsupported protocols', async () => {
await expect(checkURLForPrivateIP('ftp://example.com')).resolves.toBe(true);
});

test('should allow valid public URL', async () => {
mockLookupOnce([{ address: '8.8.8.8' }]);
expect(await checkURLForPrivateIP('http://google.com')).toBe(false);
});

test('should allow valid public IPv6', async () => {
mockLookupOnce([{ address: '2606:4700:4700::1111' }]);
await expect(checkURLForPrivateIP('https://[2606:4700:4700::1111]')).resolves.toBe(false);
});

test('should block private IPv4', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://192.168.1.1')).resolves.toBe(true);
});

test('should block localhost', async () => {
mockLookupOnce([{ address: '127.0.0.1' }]);
await expect(checkURLForPrivateIP('http://localhost')).resolves.toBe(true);
});

test('should block decimal-encoded private IP', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://3232235777')).resolves.toBe(true);
});

test('should block hex-encoded private IP', async () => {
mockLookupOnce([{ address: '192.168.1.1' }]);
await expect(checkURLForPrivateIP('http://0xC0A80101')).resolves.toBe(true);
});

test('should block cloud metadata IP', async () => {
mockLookupOnce([{ address: '169.254.169.254' }]);
await expect(checkURLForPrivateIP('http://169.254.169.254')).resolves.toBe(true);
});

test('should handle DNS resolution failure gracefully', async () => {
// @ts-expect-error fetch does not have mocked fn
dns.lookup.mockRejectedValueOnce(new Error('DNS resolution failed'));
await expect(checkURLForPrivateIP('http://unknown.domain')).resolves.toBe(true);
});
});
43 changes: 43 additions & 0 deletions app/api/metadata/proxy/feature/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export class StatusError extends Error {
status: number;
constructor(message: string, options: ErrorOptions & { cause: number }) {
super(message);
this.status = options.cause;
}
}

export const invalidRequestError = new StatusError('Invalid Request', { cause: 400 });

export const accessDeniedError = new StatusError('Access Denied', { cause: 403 });

export const resourceNotFoundError = new StatusError('Resource Not Found', { cause: 404 });

export const maxSizeError = new StatusError('Max Content Size Exceeded', { cause: 413 });

export const unsupportedMediaError = new StatusError('Unsupported Media Type', { cause: 415 });

export const generalError = new StatusError('General Error', { cause: 500 });

export const gatewayTimeoutError = new StatusError('Gateway Timeout', { cause: 504 });

export const errors = {
400: invalidRequestError,
403: accessDeniedError,
404: resourceNotFoundError,
413: maxSizeError,
415: unsupportedMediaError,
500: generalError,
504: gatewayTimeoutError,
}

export function matchAbortError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'AbortError');
}

export function matchMaxSizeError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.message.match(/over limit:/));
}

export function matchTimeoutError(error: unknown): error is Error {
return Boolean(error instanceof Error && error.name === 'TimeoutError');
}
Loading