diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index aa57e973ef..a767f413b3 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -7080,6 +7080,284 @@ describe('ParseGraphQLServer', () => { }); }); + describe("Config Queries", () => { + beforeEach(async () => { + // Setup initial config data + await Parse.Config.save( + { publicParam: 'publicValue', privateParam: 'privateValue' }, + { privateParam: true }, + { useMasterKey: true } + ); + }); + + it("should return the config value for a specific parameter", async () => { + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const result = await apolloClient.query({ + query, + variables: { paramName: 'publicParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data.cloudConfig.value).toEqual('publicValue'); + expect(result.data.cloudConfig.isMasterKeyOnly).toEqual(false); + }); + + it("should return null for non-existent parameter", async () => { + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const result = await apolloClient.query({ + query, + variables: { paramName: 'nonExistentParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data.cloudConfig.value).toBeNull(); + expect(result.data.cloudConfig.isMasterKeyOnly).toBeNull(); + }); + }); + + describe("Config Mutations", () => { + it("should update a config value using mutation and retrieve it with query", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id', + paramName: 'testParam', + value: 'testValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('testValue'); + expect(mutationResult.data.updateCloudConfig.cloudConfig.isMasterKeyOnly).toEqual(false); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'testParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('testValue'); + expect(queryResult.data.cloudConfig.isMasterKeyOnly).toEqual(false); + }); + + it("should update a config value with isMasterKeyOnly set to true", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-2', + paramName: 'privateTestParam', + value: 'privateValue', + isMasterKeyOnly: true, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('privateValue'); + expect(mutationResult.data.updateCloudConfig.cloudConfig.isMasterKeyOnly).toEqual(true); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'privateTestParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('privateValue'); + expect(queryResult.data.cloudConfig.isMasterKeyOnly).toEqual(true); + }); + + it("should update an existing config value", async () => { + await Parse.Config.save( + { existingParam: 'initialValue' }, + {}, + { useMasterKey: true } + ); + + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-3', + paramName: 'existingParam', + value: 'updatedValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('updatedValue'); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'existingParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('updatedValue'); + }); + + it("should require master key to update config", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + try { + await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-4', + paramName: 'testParam', + value: 'testValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Application-Id': 'test', + }, + }, + }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.graphQLErrors).toBeDefined(); + expect(error.graphQLErrors[0].message).toContain('Permission denied'); + } + }); + }) + describe('Users Queries', () => { it('should return current logged user', async () => { const userName = 'user1', diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index 154e774897..5ecdd78de5 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -49,7 +49,7 @@ const RESERVED_GRAPHQL_TYPE_NAMES = [ 'DeleteClassPayload', 'PageInfo', ]; -const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes']; +const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes', 'cloudConfig']; const RESERVED_GRAPHQL_MUTATION_NAMES = [ 'signUp', 'logIn', @@ -59,6 +59,7 @@ const RESERVED_GRAPHQL_MUTATION_NAMES = [ 'createClass', 'updateClass', 'deleteClass', + 'updateCloudConfig', ]; class ParseGraphQLSchema { @@ -118,6 +119,7 @@ class ParseGraphQLSchema { this.functionNamesString = functionNamesString; this.parseClassTypes = {}; this.viewerType = null; + this.cloudConfigType = null; this.graphQLAutoSchema = null; this.graphQLSchema = null; this.graphQLTypes = []; diff --git a/src/GraphQL/loaders/configMutations.js b/src/GraphQL/loaders/configMutations.js new file mode 100644 index 0000000000..c7c6176d68 --- /dev/null +++ b/src/GraphQL/loaders/configMutations.js @@ -0,0 +1,76 @@ +import { GraphQLNonNull, GraphQLString, GraphQLBoolean } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import Parse from 'parse/node'; +import { createSanitizedError } from '../../Error'; +import GlobalConfigRouter from '../../Routers/GlobalConfigRouter'; + +const globalConfigRouter = new GlobalConfigRouter(); + +const updateCloudConfig = async (context, paramName, value, isMasterKeyOnly = false) => { + const { config, auth } = context; + + if (!auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'Master Key is required to update GlobalConfig.' + ); + } + + await globalConfigRouter.updateGlobalConfig({ + body: { + params: { [paramName]: value }, + masterKeyOnly: { [paramName]: isMasterKeyOnly }, + }, + config, + auth, + context, + }); + + return { value, isMasterKeyOnly }; +}; + +const load = parseGraphQLSchema => { + const updateCloudConfigMutation = mutationWithClientMutationId({ + name: 'UpdateCloudConfig', + description: 'Updates the value of a specific parameter in GlobalConfig.', + inputFields: { + paramName: { + description: 'The name of the parameter to set.', + type: new GraphQLNonNull(GraphQLString), + }, + value: { + description: 'The value to set for the parameter.', + type: new GraphQLNonNull(GraphQLString), + }, + isMasterKeyOnly: { + description: 'Whether this parameter should only be accessible with master key.', + type: GraphQLBoolean, + defaultValue: false, + }, + }, + outputFields: { + cloudConfig: { + description: 'The updated config value.', + type: new GraphQLNonNull(parseGraphQLSchema.cloudConfigType), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { paramName, value, isMasterKeyOnly } = args; + const result = await updateCloudConfig(context, paramName, value, isMasterKeyOnly); + return { + cloudConfig: result, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(updateCloudConfigMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(updateCloudConfigMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('updateCloudConfig', updateCloudConfigMutation, true, true); +}; + +export { load, updateCloudConfig }; + diff --git a/src/GraphQL/loaders/configQueries.js b/src/GraphQL/loaders/configQueries.js new file mode 100644 index 0000000000..0320d68395 --- /dev/null +++ b/src/GraphQL/loaders/configQueries.js @@ -0,0 +1,61 @@ +import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLObjectType } from 'graphql'; +import Parse from 'parse/node'; +import { createSanitizedError } from '../../Error'; + +const cloudConfig = async (context, paramName) => { + const { config, auth } = context; + + if (!auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'Master Key is required to access GlobalConfig.' + ); + } + + const results = await config.database.find('_GlobalConfig', { objectId: '1' }, { limit: 1 }); + + if (results.length !== 1) { + return { value: null, isMasterKeyOnly: null }; + } + + const globalConfig = results[0]; + const params = globalConfig.params || {}; + const masterKeyOnly = globalConfig.masterKeyOnly || {}; + + if (params[paramName] !== undefined) { + return { value: params[paramName], isMasterKeyOnly: masterKeyOnly[paramName] ?? null }; + } + + return { value: null, isMasterKeyOnly: null }; +}; + +const load = (parseGraphQLSchema) => { + if (!parseGraphQLSchema.cloudConfigType) { + const cloudConfigType = new GraphQLObjectType({ + name: 'ConfigValue', + fields: { + value: { type: GraphQLString }, + isMasterKeyOnly: { type: GraphQLBoolean }, + }, + }); + parseGraphQLSchema.addGraphQLType(cloudConfigType, true, true); + parseGraphQLSchema.cloudConfigType = cloudConfigType; + } + + parseGraphQLSchema.addGraphQLQuery('cloudConfig', { + description: 'Returns the value of a specific parameter from GlobalConfig.', + args: { + paramName: { type: new GraphQLNonNull(GraphQLString) }, + }, + type: new GraphQLNonNull(parseGraphQLSchema.cloudConfigType), + async resolve(_source, args, context) { + try { + return await cloudConfig(context, args.paramName); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, false, true); +}; + +export { load, cloudConfig }; diff --git a/src/GraphQL/loaders/defaultGraphQLMutations.js b/src/GraphQL/loaders/defaultGraphQLMutations.js index 4d997a4822..46f513fb8e 100644 --- a/src/GraphQL/loaders/defaultGraphQLMutations.js +++ b/src/GraphQL/loaders/defaultGraphQLMutations.js @@ -2,12 +2,14 @@ import * as filesMutations from './filesMutations'; import * as usersMutations from './usersMutations'; import * as functionsMutations from './functionsMutations'; import * as schemaMutations from './schemaMutations'; +import * as configMutations from './configMutations'; const load = parseGraphQLSchema => { filesMutations.load(parseGraphQLSchema); usersMutations.load(parseGraphQLSchema); functionsMutations.load(parseGraphQLSchema); schemaMutations.load(parseGraphQLSchema); + configMutations.load(parseGraphQLSchema); }; export { load }; diff --git a/src/GraphQL/loaders/defaultGraphQLQueries.js b/src/GraphQL/loaders/defaultGraphQLQueries.js index 535cf62430..e98e02147b 100644 --- a/src/GraphQL/loaders/defaultGraphQLQueries.js +++ b/src/GraphQL/loaders/defaultGraphQLQueries.js @@ -1,6 +1,7 @@ import { GraphQLNonNull, GraphQLBoolean } from 'graphql'; import * as usersQueries from './usersQueries'; import * as schemaQueries from './schemaQueries'; +import * as configQueries from './configQueries'; const load = parseGraphQLSchema => { parseGraphQLSchema.addGraphQLQuery( @@ -16,6 +17,7 @@ const load = parseGraphQLSchema => { usersQueries.load(parseGraphQLSchema); schemaQueries.load(parseGraphQLSchema); + configQueries.load(parseGraphQLSchema); }; export { load };