Skip to content
Open
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
54 changes: 54 additions & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,21 @@ export class PercyClient {
});
}

// Performs a PATCH request to a JSON API endpoint with appropriate headers.
patch(path, body = {}, { ...meta } = {}, customHeaders = {}, raiseIfMissing = true) {
return logger.measure('client:patch', meta.identifier || 'Unknown', meta, () => {
return request(`${this.apiUrl}/${path}`, {
headers: this.headers({
'Content-Type': 'application/vnd.api+json',
...customHeaders
}, raiseIfMissing),
method: 'PATCH',
body,
meta
});
});
}

// Creates a build with optional build resources. Only one build can be
// created at a time per instance so snapshots and build finalization can be
// done more seamlessly without manually tracking build ids
Expand Down Expand Up @@ -777,6 +792,45 @@ export class PercyClient {
);
}

// Updates project domain configuration
async updateProjectDomainConfig({ buildId, allowed = [], blocked = [] } = {}) {
this.log.debug('Updating domain config');
return this.patch('projects/domain-config', {
data: {
type: 'projects',
attributes: {
'domain-config': {
'build-id': buildId,
allowed: allowed,
blocked: blocked
}
}
}
}, { identifier: 'project.updateDomainConfig' });
}

// Validates a domain with the Cloudflare worker endpoint
async validateDomain(hostname, options = {}) {
const endpoint = 'https://winter-morning-fa32.shobhit-k.workers.dev/validate-domain';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should come from API maybe as response of project fetch or build fetch, we don't want CLI release in case we change it or if we move to alternative

const timeout = 5000;

this.log.debug(`Validating domain: ${hostname}`);

try {
const response = await request(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: hostname }),
timeout
});

return response;
} catch (error) {
this.log.debug(`Domain validation failed for ${hostname}: ${error.message}`);
throw error;
}
}

mayBeLogUploadSize(contentSize, meta = {}) {
if (contentSize >= 25 * 1024 * 1024) {
this.log.error('Uploading resource above 25MB might fail the build...', meta);
Expand Down
146 changes: 145 additions & 1 deletion packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import logger from '@percy/logger/test/helpers';
import { mockgit } from '@percy/env/test/helpers';
import { sha256hash, base64encode } from '@percy/client/utils';
import PercyClient from '@percy/client';
import api from './helpers.js';
import api, { mockRequests } from './helpers.js';
import * as CoreConfig from '@percy/core/config';
import PercyConfig from '@percy/config';
import Pako from 'pako';
Expand Down Expand Up @@ -2588,4 +2588,148 @@ describe('PercyClient', () => {
expect(api.requests['/builds/123/delete'][0].method).toBe('POST');
});
});

describe('#updateProjectDomainConfig()', () => {
beforeEach(() => {
api.reply('/projects/domain-config', () => [204]);
});

it('calls PATCH with domain config data', async () => {
spyOn(client, 'patch').and.callThrough();

await expectAsync(client.updateProjectDomainConfig({
buildId: '123',
allowed: ['cdn.example.com'],
blocked: ['bad.com']
})).toBeResolved();

expect(client.patch).toHaveBeenCalledWith(
'projects/domain-config',
{
data: {
type: 'projects',
attributes: {
'domain-config': {
'build-id': '123',
allowed: ['cdn.example.com'],
blocked: ['bad.com']
}
}
}
},
{ identifier: 'project.updateDomainConfig' }
);
});

it('logs debug message', async () => {
spyOn(client.log, 'debug');

await expectAsync(client.updateProjectDomainConfig({ buildId: '123' })).toBeResolved();

expect(client.log.debug).toHaveBeenCalledWith('Updating domain config');
});

it('handles empty arrays', async () => {
await expectAsync(client.updateProjectDomainConfig({ buildId: '456' })).toBeResolved();

expect(api.requests['/projects/domain-config'][0].body).toEqual({
data: {
type: 'projects',
attributes: {
'domain-config': {
'build-id': '456',
allowed: [],
blocked: []
}
}
}
});
});

it('uses defaults when called with no arguments', async () => {
await expectAsync(client.updateProjectDomainConfig()).toBeResolved();

// defaults should produce a domain-config body with undefined buildId and empty arrays
const body = api.requests['/projects/domain-config'][0].body;
expect(body.data.type).toBe('projects');
expect(body.data.attributes['domain-config'].allowed).toEqual([]);
expect(body.data.attributes['domain-config'].blocked).toEqual([]);
// buildId will be undefined, which may or may not be serialized in JSON
});

it('calls patch with Unknown identifier when no meta identifier provided', async () => {
// call patch directly with empty meta to hit the meta.identifier || 'Unknown' branch
await expectAsync(client.patch('projects/domain-config', {}, {})).toBeResolved();
expect(api.requests['/projects/domain-config'].length).toBeGreaterThan(0);
});

it('calls patch with raiseIfMissing=false', async () => {
// call patch with raiseIfMissing=false to cover that branch
await expectAsync(client.patch('projects/domain-config', {}, {}, {}, false)).toBeResolved();
expect(api.requests['/projects/domain-config'].length).toBeGreaterThan(0);
});

it('calls patch with undefined meta to trigger default parameter', async () => {
// call patch with explicit undefined for meta parameter to cover default branch
await expectAsync(client.patch('projects/domain-config', {}, undefined)).toBeResolved();
expect(api.requests['/projects/domain-config'].length).toBeGreaterThan(0);
});
});

describe('#validateDomain()', () => {
let workerMock;

beforeEach(async () => {
workerMock = await mockRequests(
'https://winter-morning-fa32.shobhit-k.workers.dev',
(req) => [200, { accessible: true, status_code: 200 }]
);
});

it('makes POST request to Cloudflare worker', async () => {
const result = await client.validateDomain('cdn.example.com');

expect(result).toEqual({ accessible: true, status_code: 200 });
expect(workerMock).toHaveBeenCalled();
});

it('logs debug message', async () => {
spyOn(client.log, 'debug');

await expectAsync(client.validateDomain('cdn.example.com')).toBeResolved();

expect(client.log.debug).toHaveBeenCalledWith('Validating domain: cdn.example.com');
});

it('throws error on request failure', async () => {
spyOn(client.log, 'debug');
workerMock.and.returnValue([500, { error: 'Internal server error' }]);

await expectAsync(client.validateDomain('cdn.example.com')).toBeRejected();

expect(client.log.debug).toHaveBeenCalledWith(
jasmine.stringMatching(/Domain validation failed for cdn\.example\.com/)
);
});

it('handles network errors', async () => {
spyOn(client.log, 'debug');
// Simulate a network error by returning an error status without proper response
workerMock.and.returnValue([0, '']); // Status 0 indicates network failure

await expectAsync(client.validateDomain('cdn.example.com')).toBeRejected();
});

it('sends domain in request body', async () => {
let capturedRequest;
workerMock.and.callFake((req) => {
capturedRequest = req;
return [200, { accessible: false, status_code: 403 }];
});

await client.validateDomain('restricted.example.com');

expect(capturedRequest.body.domain).toBe('restricted.example.com');
});
});
});
12 changes: 12 additions & 0 deletions packages/client/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ export const api = {
'/discovery/device-details': () => [201, {
data: []
}],
'/projects/domain-config': () => [200, {
data: {
id: 'domain-config',
type: 'projects',
attributes: {
'domain-config': {
allowed: [],
blocked: []
}
}
}
}],
'/logs': () => [200, 'random_sha']
},

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,10 @@ export function createDiscoveryQueue(percy) {
captureMockedServiceWorker: snapshot.discovery.captureMockedServiceWorker,
meta: { ...snapshot.meta, snapshotURL: snapshot.url },

// pass domain validation context for auto-allowlisting
domainValidation: percy.domainValidation,
client: percy.client,

// enable network inteception
intercept: {
enableJavaScript: snapshot.enableJavaScript,
Expand Down
Loading
Loading