Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
167 changes: 167 additions & 0 deletions spec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,4 +685,171 @@ describe('server', () => {
})
.catch(done.fail);
});

describe('publicServerURL', () => {
it('should load publicServerURL', async () => {
await reconfigureServer({
publicServerURL: () => 'https://example.com/1',
});

await new Parse.Object('TestObject').save();

const config = Config.get(Parse.applicationId);
expect(config.publicServerURL).toEqual('https://example.com/1');
});

it('should load publicServerURL from Promise', async () => {
await reconfigureServer({
publicServerURL: () => Promise.resolve('https://example.com/1'),
});

await new Parse.Object('TestObject').save();

const config = Config.get(Parse.applicationId);
expect(config.publicServerURL).toEqual('https://example.com/1');
});

it('should handle publicServerURL function throwing error', async () => {
const errorMessage = 'Failed to get public server URL';
await reconfigureServer({
publicServerURL: () => {
throw new Error(errorMessage);
},
});

// The error should occur when trying to save an object (which triggers loadKeys in middleware)
await expectAsync(
new Parse.Object('TestObject').save()
).toBeRejected();
});

it('should handle publicServerURL Promise rejection', async () => {
const errorMessage = 'Async fetch of public server URL failed';
await reconfigureServer({
publicServerURL: () => Promise.reject(new Error(errorMessage)),
});

// The error should occur when trying to save an object (which triggers loadKeys in middleware)
await expectAsync(
new Parse.Object('TestObject').save()
).toBeRejected();
});

it('executes publicServerURL function on every config access', async () => {
let counter = 0;
await reconfigureServer({
publicServerURL: () => {
counter++;
return `https://example.com/${counter}`;
},
});

// First request - should call the function
await new Parse.Object('TestObject').save();
const config1 = Config.get(Parse.applicationId);
expect(config1.publicServerURL).toEqual('https://example.com/1');
expect(counter).toEqual(1);

// Second request - should call the function again
await new Parse.Object('TestObject').save();
const config2 = Config.get(Parse.applicationId);
expect(config2.publicServerURL).toEqual('https://example.com/2');
expect(counter).toEqual(2);

// Third request - should call the function again
await new Parse.Object('TestObject').save();
const config3 = Config.get(Parse.applicationId);
expect(config3.publicServerURL).toEqual('https://example.com/3');
expect(counter).toEqual(3);
});

it('executes publicServerURL function on every password reset email', async () => {
let counter = 0;
const emailCalls = [];

const emailAdapter = MockEmailAdapterWithOptions({
sendPasswordResetEmail: ({ link }) => {
emailCalls.push(link);
return Promise.resolve();
},
});

await reconfigureServer({
appName: 'test-app',
publicServerURL: () => {
counter++;
return `https://example.com/${counter}`;
},
emailAdapter,
});

// Create a user
const user = new Parse.User();
user.setUsername('user');
user.setPassword('pass');
user.setEmail('[email protected]');
await user.signUp();

// Should use first publicServerURL
const counterBefore1 = counter;
await Parse.User.requestPasswordReset('[email protected]');
await jasmine.timeout();
expect(emailCalls.length).toEqual(1);
expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);
expect(counter).toBeGreaterThanOrEqual(2);

// Should use updated publicServerURL
const counterBefore2 = counter;
await Parse.User.requestPasswordReset('[email protected]');
await jasmine.timeout();
expect(emailCalls.length).toEqual(2);
expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
expect(counterBefore2).toBeGreaterThan(counterBefore1);
});

it('executes publicServerURL function on every verification email', async () => {
let counter = 0;
const emailCalls = [];

const emailAdapter = MockEmailAdapterWithOptions({
sendVerificationEmail: ({ link }) => {
emailCalls.push(link);
return Promise.resolve();
},
});

await reconfigureServer({
appName: 'test-app',
verifyUserEmails: true,
publicServerURL: () => {
counter++;
return `https://example.com/${counter}`;
},
emailAdapter,
});

// Should trigger verification email with first publicServerURL
const counterBefore1 = counter;
const user1 = new Parse.User();
user1.setUsername('user1');
user1.setPassword('pass1');
user1.setEmail('[email protected]');
await user1.signUp();
await jasmine.timeout();
expect(emailCalls.length).toEqual(1);
expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);

// Should trigger verification email with updated publicServerURL
const counterBefore2 = counter;
const user2 = new Parse.User();
user2.setUsername('user2');
user2.setPassword('pass2');
user2.setEmail('[email protected]');
await user2.signUp();
await jasmine.timeout();
expect(emailCalls.length).toEqual(2);
expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
expect(counterBefore2).toBeGreaterThan(counterBefore1);
});
});
});
63 changes: 53 additions & 10 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function removeTrailingSlash(str) {
return str;
}

const asyncKeys = ['publicServerURL'];
export class Config {
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
Expand All @@ -56,9 +57,33 @@ export class Config {
return config;
}

async loadKeys() {
const asyncKeys = ['publicServerURL'];

await Promise.all(
asyncKeys.map(async key => {
if (typeof this[`_${key}`] === 'function') {
this[key] = await this[`_${key}`]();
}
})
);

AppCache.put(this.appId, this);
}

static transformConfiguration(serverConfiguration) {
for (const key of Object.keys(serverConfiguration)) {
if (asyncKeys.includes(key) && typeof serverConfiguration[key] === 'function') {
serverConfiguration[`_${key}`] = serverConfiguration[key];
delete serverConfiguration[key];
}
}
}

static put(serverConfiguration) {
Config.validateOptions(serverConfiguration);
Config.validateControllers(serverConfiguration);
Config.transformConfiguration(serverConfiguration);
AppCache.put(serverConfiguration.appId, serverConfiguration);
Config.setupPasswordValidator(serverConfiguration.passwordPolicy);
return serverConfiguration;
Expand Down Expand Up @@ -115,11 +140,7 @@ export class Config {
throw 'extendSessionOnUse must be a boolean value';
}

if (publicServerURL) {
if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
throw 'publicServerURL should be a valid HTTPS URL starting with https://';
}
}
this.validatePublicServerURL({ publicServerURL });
this.validateSessionConfiguration(sessionLength, expireInactiveSessions);
this.validateIps('masterKeyIps', masterKeyIps);
this.validateIps('maintenanceKeyIps', maintenanceKeyIps);
Expand Down Expand Up @@ -154,6 +175,7 @@ export class Config {
userController,
appName,
publicServerURL,
_publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
}) {
Expand All @@ -162,7 +184,7 @@ export class Config {
this.validateEmailConfiguration({
emailAdapter,
appName,
publicServerURL,
publicServerURL: publicServerURL || _publicServerURL,
emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid,
});
Expand Down Expand Up @@ -432,6 +454,30 @@ export class Config {
}
}

static validatePublicServerURL({ publicServerURL, required = false }) {
if (!publicServerURL) {
if (!required) {
return;
}
throw 'The option publicServerURL is required.';
}

const type = typeof publicServerURL;

if (type === 'string') {
if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
throw 'The option publicServerURL must be a valid URL starting with http:// or https://.';
}
return;
}

if (type === 'function') {
return;
}

throw `The option publicServerURL must be a string or function, but got ${type}.`;
}

static validateEmailConfiguration({
emailAdapter,
appName,
Expand All @@ -445,9 +491,7 @@ export class Config {
if (typeof appName !== 'string') {
throw 'An app name is required for e-mail verification and password resets.';
}
if (typeof publicServerURL !== 'string') {
throw 'A public server url is required for e-mail verification and password resets.';
}
this.validatePublicServerURL({ publicServerURL, required: true });
if (emailVerifyTokenValidityDuration) {
if (isNaN(emailVerifyTokenValidityDuration)) {
throw 'Email verify token validity duration must be a valid number.';
Expand Down Expand Up @@ -757,7 +801,6 @@ export class Config {
return this.masterKey;
}


// TODO: Remove this function once PagesRouter replaces the PublicAPIRouter;
// the (default) endpoint has to be defined in PagesRouter only.
get pagesEndpoint() {
Expand Down
2 changes: 1 addition & 1 deletion src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export class UsersRouter extends ClassesRouter {
Config.validateEmailConfiguration({
emailAdapter: req.config.userController.adapter,
appName: req.config.appName,
publicServerURL: req.config.publicServerURL,
publicServerURL: req.config.publicServerURL || req.config._publicServerURL,
emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration,
emailVerifyTokenReuseIfValid: req.config.emailVerifyTokenReuseIfValid,
});
Expand Down
1 change: 1 addition & 0 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export async function handleParseHeaders(req, res, next) {
});
return;
}
await config.loadKeys();

info.app = AppCache.get(info.appId);
req.config = config;
Expand Down
2 changes: 1 addition & 1 deletion types/Options/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export interface ParseServerOptions {
cacheAdapter?: Adapter<CacheAdapter>;
emailAdapter?: Adapter<MailAdapter>;
encodeParseObjectInCloudFunction?: boolean;
publicServerURL?: string;
publicServerURL?: string | (() => string) | Promise<string>;
pages?: PagesOptions;
customPages?: CustomPagesOptions;
liveQuery?: LiveQueryOptions;
Expand Down
Loading