diff --git a/e2e-tests/src/tests/bindings.test.ts b/e2e-tests/src/tests/bindings.test.ts new file mode 100644 index 0000000000..8b67eb4f74 --- /dev/null +++ b/e2e-tests/src/tests/bindings.test.ts @@ -0,0 +1,639 @@ +/** + * Test the Action Bindings for Merlin and the Scheduler + * + * Health endpoints are already tested in health.test.ts + */ +import { expect, test } from '@playwright/test'; +import req, {awaitSimulation} from '../utilities/requests.js'; +import time from "../utilities/time.js"; +import * as urls from '../utilities/urls.js'; + +test.describe('Merlin Bindings', () => { + let mission_model_id: number; + let plan_id: number; + let admin: User = { + username: "Admin_User", + default_role: "aerie_admin", + allowed_roles: ["aerie_admin"], + session: {'x-hasura-role': 'aerie_admin', 'x-hasura-user-id': 'Admin_User'} + }; + let nonOwner: User = { + username: "not_owner", + default_role: "user", + allowed_roles: ["user", "viewer"], + session: {'x-hasura-role': 'user', 'x-hasura-user-id': 'not_owner'} + }; + + test.beforeAll(async ({ request }) => { + // Insert the users + await req.createUser(request, admin); + await req.createUser(request, nonOwner); + + // Insert the Mission Model + let rd = Math.random()*100000; + let jar_id = await req.uploadJarFile(request); + const model: MissionModelInsertInput = { + jar_id, + mission: 'aerie_e2e_tests', + name: 'Banananation (e2e tests)', + version: rd + "", + }; + mission_model_id = await req.createMissionModel(request, model); + + const plan_start_timestamp = "2023-01-01T00:00:00+00:00"; + const plan_end_timestamp = "2023-01-02T00:00:00+00:00"; + // Insert the Plan + const plan_input : CreatePlanInput = { + model_id : mission_model_id, + name : 'test_plan_' + rd, + start_time : plan_start_timestamp, + duration : time.getIntervalFromDoyRange(plan_start_timestamp, plan_end_timestamp) + }; + plan_id = await req.createPlan(request, plan_input, admin.session); + }); + test.afterAll(async ({ request }) => { + // Remove Model and Plan + await req.deleteMissionModel(request, mission_model_id); + await req.deletePlan(request, plan_id); + + // Remove Users + await req.deleteUser(request, admin.username); + await req.deleteUser(request, nonOwner.username); + }); + + // "resourceTypes" and "getActivityEffectiveArguments" are not tested, as they are deprecated + test('GetSimulationResults', async ({request}) => { + // Create a custom plan for this test, so that sim results don't affect later tests + const local_plan_input : CreatePlanInput = { + model_id : mission_model_id, + name : 'test_plan_' + Math.random()*100000, + start_time : "2023-01-01T00:00:00+00:00", + duration : "24:00:00" + }; + const local_plan_id = await req.createPlan(request, local_plan_input, admin.session); + + // Returns a 404 if the PlanId is invalid + // message is "no such plan" + let response = await request.post(`${urls.MERLIN_URL}/getSimulationResults`, { + data: { + action: {name: "simulate"}, + input: {planId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan') + + + // Returns a 403 if Unauthorized + response = await request.post(`${urls.MERLIN_URL}/getSimulationResults`, { + data: { + action: {name: "simulate"}, + input: {planId: local_plan_id}, + request_query: "", + session_variables: nonOwner.session}}); + expect(response.status()).toEqual(403); + expect((await response.json()).message).toEqual( + "User '"+nonOwner.username+"' with role 'user' cannot perform 'simulate' because they are not " + + "a 'PLAN_OWNER_COLLABORATOR' for plan with id '"+local_plan_id+"'"); + + // Returns a 200 otherwise + // "status" is "pending" + response = await request.post(`${urls.MERLIN_URL}/getSimulationResults`, { + data: { + action: {name: "simulate"}, + input: {planId: local_plan_id}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json()).status).toEqual('pending'); + + // Cleanup sim results + await awaitSimulation(request, local_plan_id); + await req.deletePlan(request, local_plan_id) + }); + test('ResourceSamples', async ({request}) => { + // Returns a 404 if the PlanId is invalid + // message is "no such plan" + let response = await request.post(`${urls.MERLIN_URL}/resourceSamples`, { + data: { + action: {name: "resource_samples"}, + input: {planId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan') + + + // 403: Unauthorized requires updating permissions + const og_permissions = await req.getActionPermissionsForRole(request, "user"); + const temp_permissions: ActionPermissionSet = { + resource_samples: "PLAN_OWNER", + simulate: null, + schedule: null, + sequence_seq_json_bulk: null, + check_constraints: null, + create_expansion_rule: null, + create_expansion_set: null, + insert_ext_dataset: null, + expand_all_activities: null, + }; + await req.updateActionPermissionsForRole(request, "user", temp_permissions ); + + response = await request.post(`${urls.MERLIN_URL}/resourceSamples`, { + data: { + action: {name: "resource_samples"}, + input: {planId: plan_id}, + request_query: "", + session_variables: nonOwner.session}}); + expect(response.status()).toEqual(403); + expect((await response.json()).message).toEqual( + "User '"+nonOwner.username+"' with role 'user' cannot perform 'resource_samples' because " + + "they are not a 'PLAN_OWNER' for plan with id '"+plan_id+"'"); + + await req.updateActionPermissionsForRole(request, "user", og_permissions); + expect(og_permissions).toEqual(await req.getActionPermissionsForRole(request, "user")); + + // Returns a 200 otherwise + // "resourceSamples" is empty because there is no sim data + response = await request.post(`${urls.MERLIN_URL}/resourceSamples`, { + data: { + action: {name: "resource_samples"}, + input: {planId: plan_id}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json()).resourceSamples).toEqual({}); + }); + test('ConstraintViolations', async ({request}) => { + // Returns a 404 if the PlanId is invalid + // message is "no such plan" + let response = await request.post(`${urls.MERLIN_URL}/constraintViolations`, { + data: { + action: {name: "check_constraints"}, + input: {planId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan'); + + // Returns a 403 if unauthorized + response = await request.post(`${urls.MERLIN_URL}/constraintViolations`, { + data: { + action: {name: "check_constraints"}, + input: {planId: plan_id}, + request_query: "", + session_variables: nonOwner.session}}); + expect(response.status()).toEqual(403); + expect((await response.json()).message).toEqual( + "User '"+nonOwner.username+"' with role 'user' cannot perform 'check_constraints' because they are not " + + "a 'PLAN_OWNER_COLLABORATOR' for plan with id '"+plan_id+"'"); + + // Returns a 200 otherwise + // "violations" is empty because there are no constraints that could've failed + response = await request.post(`${urls.MERLIN_URL}/constraintViolations`, { + data: { + action: {name: "check_constraints"}, + input: {planId: plan_id}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json()).violations).toEqual([]); + }); + test('RefreshModelParameters', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/refreshModelParameters`, { + data: { event: { data: {old: null, new: {id: -1}}}}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 if the ID is valid + // There is no response body from this endpoint and awaiting it causes an "unexpected end of JSON input" error + response = await request.post(`${urls.MERLIN_URL}/refreshModelParameters`, { + data: { event: { data: {old: null, new: {id: mission_model_id}}}}}); + expect(response.status()).toEqual(200); + }); + test('RefreshActivityTypes', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/refreshActivityTypes`, { + data: { event: { data: {old: null, new: {id: -1}}}}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 if the ID is valid + // There is no response body from this endpoint and awaiting it causes an "unexpected end of JSON input" error + response = await request.post(`${urls.MERLIN_URL}/refreshActivityTypes`, { + data: { event: { data: {old: null, new: {id: mission_model_id}}}}}); + expect(response.status()).toEqual(200); + }); + test('RefreshResourceTypes', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/refreshResourceTypes`, { + data: { event: { data: {old: null, new: {id: -1}}}}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 if the ID is valid + // There is no response body from this endpoint and awaiting it causes an "unexpected end of JSON input" error + response = await request.post(`${urls.MERLIN_URL}/refreshResourceTypes`, { + data: { event: { data: {old: null, new: {id: mission_model_id}}}}}); + expect(response.status()).toEqual(200); + }); + test('ValidateActivityArguments', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/validateActivityArguments`, { + data: { + action: {name: "validateActivityArguments"}, + input: { missionModelId: '-1', activityTypeName: "BiteBanana", activityArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 otherwise + // "success" is true + response = await request.post(`${urls.MERLIN_URL}/validateActivityArguments`, { + data: { + action: {name: "validateActivityArguments"}, + input: {missionModelId: ""+mission_model_id, activityTypeName: "BiteBanana", activityArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ success:true }); + }); + test('ValidateModelArguments', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/validateModelArguments`, { + data: { + action: {name: "validateModelArguments"}, + input: { missionModelId: '-1', modelArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + + // Returns a 200 if the ID is valid + response = await request.post(`${urls.MERLIN_URL}/validateModelArguments`, { + data: { + action: {name: "validateModelArguments"}, + input: { missionModelId:''+mission_model_id, modelArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ success:true }); + }); + test('ValidatePlan', async ({request}) => { + // Returns a 404 if the PlanId is invalid + // message is "no such plan" + let response = await request.post(`${urls.MERLIN_URL}/validatePlan`, { + data: { + action: {name: "validatePlan"}, + input: { planId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan'); + + // Returns a 200 otherwise + response = await request.post(`${urls.MERLIN_URL}/validatePlan`, { + data: { + action: {name: "validatePlan"}, + input: { planId: plan_id}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ success:true }); + }); + test('GetModelEffectiveArguments', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/getModelEffectiveArguments`, { + data: { + action: {name: "getModelEffectiveArguments"}, + input: { missionModelId: '-1', modelArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 otherwise + // Body contains the complete set of args for the mission model (all default in this case) + response = await request.post(`${urls.MERLIN_URL}/getModelEffectiveArguments`, { + data: { + action: {name: "getModelEffectiveArguments"}, + input: { missionModelId:''+mission_model_id, modelArguments: {}}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ + success:true, + arguments: { + initialPlantCount: 200, + initialDataPath: '/etc/os-release', + initialProducer: 'Chiquita', + initialConditions: { peel: 4, fruit: 4, flag: 'A' }}}); + }); + test('GetActivityEffectiveArgumentsBulk', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/getActivityEffectiveArgumentsBulk`, { + data: { + action: {name: "getActivityEffectiveArgumentsBulk"}, + input: { missionModelId: '-1', activities: [] }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such mission model'); + + // Returns a 200 otherwise + // Body contains the complete set of args for the mission model (all default in this case) + response = await request.post(`${urls.MERLIN_URL}/getActivityEffectiveArgumentsBulk`, { + data: { + action: {name: "getActivityEffectiveArgumentsBulk"}, + input: { + missionModelId:''+mission_model_id, + activities: [ + {activityTypeName: "GrowBanana", activityArguments: {}}, + {activityTypeName: "GrowBanana", activityArguments: {quantity: 100}} + ] + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual([ + { + typeName: 'GrowBanana', + success: true, + arguments: { growingDuration: 3600000000, quantity: 1 } + }, + { + typeName: 'GrowBanana', + success: true, + arguments: { growingDuration: 3600000000, quantity: 100 } + }]); + }); + test('AddExternalDataset', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/addExternalDataset`, { + data: { + action: {name: "addExternalDataset"}, + input: { + planId: -1, + datasetStart:'2021-001T06:00:00.000', + profileSet: {'/my_boolean':{schema:{type:'boolean'},segments:[{duration:3600000000,dynamics:true}],type:'discrete'}}, + simulationDatasetId:null + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan'); + + // Returns a 201 otherwise + response = await request.post(`${urls.MERLIN_URL}/addExternalDataset`, { + data: { + action: {name: "addExternalDataset"}, + input: { + planId: plan_id, + datasetStart:'2021-001T06:00:00.000', + profileSet: {'/my_boolean':{schema:{type:'boolean'},segments:[{duration:3600000000,dynamics:true}],type:'discrete'}}, + simulationDatasetId: null + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(201); + expect((await response.json()).datasetId).toBeDefined(); + }); + test('ExtendExternalDataset', async ({request}) => { + // Returns a 404 if the MissionModelId is invalid + // message is "no such mission model" + let response = await request.post(`${urls.MERLIN_URL}/extendExternalDataset`, { + data: { + action: {name: "extendExternalDataset"}, + input: { + datasetId:-1, + profileSet: {'/my_boolean':{schema:{type:'boolean'},segments:[{duration:3600000000,dynamics:true}],type:'discrete'}} + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such plan dataset'); + + // Setup + const datasetInput : ExternalDatasetInsertInput = { + plan_id: plan_id, + profile_set: {'/my_boolean':{schema:{type:'boolean'},segments:[{duration:3600000000,dynamics:true}],type:'discrete'}}, + dataset_start: '2021-001T06:00:00.000' + } + const dataset_id = await req.insertExternalDataset(request, datasetInput); + + // Returns a 200 if the ID is valid + response = await request.post(`${urls.MERLIN_URL}/extendExternalDataset`, { + data: { + action: {name: "extendExternalDataset"}, + input: { + datasetId:dataset_id, + profileSet: datasetInput.profile_set + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json())).toEqual({datasetId: dataset_id}); + }); + test('ConstraintsDslTypescript', async ({request}) => { + // Returns a 200 with a failure status if the MissionModelId is invalid + // reason is "No mission model exists with id `-1`" + let response = await request.post(`${urls.MERLIN_URL}/constraintsDslTypescript`, { + data: { + action: {name: "constraintsDslTypescript"}, + input: { + missionModelId:'-1', + planId: null, + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json())).toEqual({ status: 'failure', reason: 'No mission model exists with id `-1`' }); + + // TODO: Uncomment this test and update the below comment once this behavior has been fixed + // Expectation: According to `GenerateConstraintsLibAction::run`, this request should fail + // with reason = 'No plan exists with id `-1`'. + // However, PostgresPlanRepository's implementation of `getExternalResourceSchemas` doesn't throw NoSuchPlanException, + // it returns an empty list if planId doesn't exist + /* + response = await request.post(`${urls.MERLIN_URL}/constraintsDslTypescript`, { + data: { + action: {name: "constraintsDslTypescript"}, + input: { + missionModelId:''+mission_model_id, + planId: -1, + }, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect((await response.json())).toEqual({ status: 'failure', reason: 'No plan exists with id `-1`'}); + */ + + // Returns a 200 with a success status if the ID is valid + response = await request.post(`${urls.MERLIN_URL}/constraintsDslTypescript`, { + data: { + action: {name: "constraintsDslTypescript"}, + input: { + missionModelId:''+mission_model_id, + planId: null, + }, + request_query: "", + session_variables: admin.session}}); + let respBody = await response.json(); + expect(response.status()).toEqual(200); + expect(respBody.status).toEqual('success'); + expect(respBody.typescriptFiles).not.toBeNull(); + expect(respBody.typescriptFiles.length).toBeGreaterThan(0); + respBody.typescriptFiles.forEach( + file => { + expect(file.filePath).not.toBeNull(); + expect(file.content).not.toBeNull(); + expect(file.content.length).toBeGreaterThan(0); + }); + }); +}); + +test.describe.serial('Scheduler Bindings', () => { + let mission_model_id: number; + let plan_id: number; + let scheduling_spec_id: number; + let admin: User = { + username: "Admin_User", + default_role: "aerie_admin", + allowed_roles: ["aerie_admin"], + session: {'x-hasura-role': 'aerie_admin', 'x-hasura-user-id': 'Admin_User'} + }; + let nonOwner: User = { + username: "not_owner", + default_role: "user", + allowed_roles: ["user"], + session: {'x-hasura-role': 'user', 'x-hasura-user-id': 'not_owner'} + }; + + test.beforeAll(async ({ request }) => { + // Insert the users + await req.createUser(request, admin); + await req.createUser(request, nonOwner); + + // Insert the Mission Model + let rd = Math.random()*100000; + let jar_id = await req.uploadJarFile(request); + const model: MissionModelInsertInput = { + jar_id, + mission: 'aerie_e2e_tests', + name: 'Banananation (e2e tests)', + version: rd + "", + }; + mission_model_id = await req.createMissionModel(request, model); + + // Insert the Plan + const plan_start_timestamp = "2023-01-01T00:00:00+00:00"; + const plan_end_timestamp = "2023-01-02T00:00:00+00:00"; + + const plan_input : CreatePlanInput = { + model_id : mission_model_id, + name : 'test_plan_' + rd, + start_time : plan_start_timestamp, + duration : time.getIntervalFromDoyRange(plan_start_timestamp, plan_end_timestamp) + }; + plan_id = await req.createPlan(request, plan_input, admin.session); + + // Insert the Scheduling Spec + const schedulingSpecification : SchedulingSpecInsertInput = { + horizon_end: plan_end_timestamp, + horizon_start: plan_start_timestamp, + plan_id : plan_id, + plan_revision : await req.getPlanRevision(request, plan_id), + simulation_arguments : {}, + analysis_only: true + } + scheduling_spec_id = await req.insertSchedulingSpecification(request, schedulingSpecification); + }); + test.afterAll(async ({ request }) => { + // Remove Model and Plan + await req.deleteMissionModel(request, mission_model_id); + await req.deletePlan(request, plan_id); + + // Remove Users + await req.deleteUser(request, admin.username); + await req.deleteUser(request, nonOwner.username); + }); + + test('Schedule', async ({request}) => { + // Returns a 404 if the SpecId is invalid + // message is "no such scheduling specification" + let response = await request.post(`${urls.SCHEDULER_URL}/schedule`, { + data: { + action: {name: "schedule"}, + input: {specificationId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(404); + expect((await response.json()).message).toEqual('no such scheduling specification') + + // Returns a 403 if the user isn't authorized to schedule + response = await request.post(`${urls.SCHEDULER_URL}/schedule`, { + data: { + action: {name: "schedule"}, + input: {specificationId: scheduling_spec_id}, + request_query: "", + session_variables: nonOwner.session}}); + expect(response.status()).toEqual(403); + expect((await response.json()).message).toEqual( + "User '"+nonOwner.username+"' with role 'user' cannot perform 'schedule' because they are not " + + "a 'PLAN_OWNER_COLLABORATOR' for plan with id '"+plan_id+"'"); + + // Returns a 200 if the ID is valid + response = await request.post(`${urls.SCHEDULER_URL}/schedule`, { + data: { + action: {name: "schedule"}, + input: {specificationId: scheduling_spec_id}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200) + }); + test('SchedulingDslTypescript', async ({request}) => { + // Returns a 200 with a failure status if the MissionModelId is invalid + // reason is "No mission model exists with id `MissionModelId[id=-1]`" + let response = await request.post(`${urls.SCHEDULER_URL}/schedulingDslTypescript`, { + data: { + action: {name: "schedulingDslTypescript"}, + input: {missionModelId: -1}, + request_query: "", + session_variables: admin.session}}); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ + status: 'failure', + reason: 'No mission model exists with id `MissionModelId[id=-1]`' + }); + + // Returns a 200 with a success status if the ID is valid + response = await request.post(`${urls.SCHEDULER_URL}/schedulingDslTypescript`, { + data: { + action: {name: "schedulingDslTypescript"}, + input: {missionModelId: mission_model_id}, + request_query: "", + session_variables: admin.session}}); + let respBody = await response.json(); + expect(response.status()).toEqual(200); + expect(respBody.status).toEqual('success'); + expect(respBody.typescriptFiles).not.toBeNull(); + expect(respBody.typescriptFiles.length).toBeGreaterThan(0); + respBody.typescriptFiles.forEach( + file => { + expect(file.filePath).not.toBeNull(); + expect(file.content).not.toBeNull(); + expect(file.content.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/e2e-tests/src/types/user.d.ts b/e2e-tests/src/types/user.d.ts new file mode 100644 index 0000000000..7a1dd13599 --- /dev/null +++ b/e2e-tests/src/types/user.d.ts @@ -0,0 +1,27 @@ +type User = { + username: string; + default_role: string; + allowed_roles: string[]; + session: Record; +}; + +type UserInsert = Omit + +type UserAllowedRole = { + username: string, + allowed_role: string; +} + +type Permission = "NO_CHECK" | "OWNER" | "MISSION_MODEL_OWNER" | "PLAN_OWNER" | "PLAN_COLLABORATOR" | "PLAN_OWNER_COLLABORATOR" + +type ActionPermissionSet = { + simulate: Permission | null, + schedule: Permission | null, + insert_ext_dataset: Permission | null, + check_constraints: Permission | null, + create_expansion_set: Permission | null, + create_expansion_rule: Permission | null, + expand_all_activities: Permission | null, + resource_samples: Permission | null, + sequence_seq_json_bulk: Permission | null +} diff --git a/e2e-tests/src/utilities/gql.ts b/e2e-tests/src/utilities/gql.ts index f9b0eab3bb..86ccd069bf 100644 --- a/e2e-tests/src/utilities/gql.ts +++ b/e2e-tests/src/utilities/gql.ts @@ -431,6 +431,57 @@ const gql = { } `, + CREATE_USER: `#graphql + mutation createUser($user: users_insert_input!, $allowed_roles: [users_allowed_roles_insert_input!]!) { + insert_users_one(object: $user) { + default_role + username + } + insert_users_allowed_roles(objects: $allowed_roles) { + returning { + allowed_role + username + } + } + } + `, + + DELETE_USER: `#graphql + mutation deleteUser($username: String!) { + delete_users_by_pk(username: $username) { + username + default_role + } + } + `, + + ADD_PLAN_COLLABORATOR: `#graphql + mutation addPlanCollaborator($collaborator: plan_collaborators_insert_input!) { + insert_plan_collaborators_one(object: $collaborator) { + collaborator + plan_id + } + } + `, + + GET_ROLE_ACTION_PERMISSIONS: `#graphl + query getRolePermissions($role: user_roles_enum!){ + permissions: user_role_permission_by_pk(role: $role) { + action_permissions + } + } + `, + + UPDATE_ROLE_ACTION_PERMISSIONS: `#graphl + mutation updateRolePermissions($role: user_roles_enum!, $action_permissions: jsonb!) { + permissions: update_user_role_permission_by_pk( + pk_columns: {role: $role}, + _set: {action_permissions: $action_permissions}) + { + action_permissions + } + } + ` }; export default gql; diff --git a/e2e-tests/src/utilities/requests.ts b/e2e-tests/src/utilities/requests.ts index 78577ec085..e0c82e92a7 100644 --- a/e2e-tests/src/utilities/requests.ts +++ b/e2e-tests/src/utilities/requests.ts @@ -10,8 +10,8 @@ import time from './time.js'; * Aerie API request functions. */ const req = { - async createMissionModel(request: APIRequestContext, model: MissionModelInsertInput): Promise { - const data = await req.hasura(request, gql.CREATE_MISSION_MODEL, { model: model }); + async createMissionModel(request: APIRequestContext, model: MissionModelInsertInput, headers?: Record): Promise { + const data = await req.hasura(request, gql.CREATE_MISSION_MODEL, { model: model }, headers); const { insert_mission_model_one } = data; const { id: mission_model_id } = insert_mission_model_one; @@ -126,8 +126,8 @@ const req = { } }, - async createPlan(request: APIRequestContext, model: CreatePlanInput): Promise { - const data = await req.hasura(request, gql.CREATE_PLAN, { plan: model }); + async createPlan(request: APIRequestContext, model: CreatePlanInput, headers?: Record): Promise { + const data = await req.hasura(request, gql.CREATE_PLAN, { plan: model }, headers); const { insert_plan_one } = data; const { id: plan_id } = insert_plan_one; return plan_id; @@ -385,6 +385,34 @@ const req = { const { constraint_run } = data; return constraint_run; }, + + // User-related requests + async createUser(request: APIRequestContext, user: User): Promise { + const userInput: UserInsert = {username: user.username, default_role: user.default_role}; + const allowedRolesInput: UserAllowedRole[] = user.allowed_roles.map(role => { return {username: user.username, allowed_role: role}}) + await req.hasura(request, gql.CREATE_USER, { user: userInput, allowed_roles: allowedRolesInput } ) + }, + + async deleteUser(request: APIRequestContext, username: string): Promise { + await req.hasura(request, gql.DELETE_USER, { username }) + }, + + async addPlanCollaborator(request: APIRequestContext, username: string, planId: number): Promise { + const planCollaboratorInsertInput = {planId: planId, collaborator: username}; + await req.hasura(request, gql.ADD_PLAN_COLLABORATOR, { planCollaboratorInsertInput }); + }, + + async getActionPermissionsForRole(request: APIRequestContext, role: string): Promise { + const data = await req.hasura(request, gql.GET_ROLE_ACTION_PERMISSIONS, { role }); + const { permissions } = data; + const { action_permissions } = permissions; + return action_permissions; + }, + + async updateActionPermissionsForRole(request: APIRequestContext, role: string, permissions: ActionPermissionSet): Promise { + const strippedPermissions = Object.fromEntries(Object.entries(permissions).filter(([_, v]) => v != null)); + await req.hasura(request, gql.UPDATE_ROLE_ACTION_PERMISSIONS, { role: role, action_permissions: strippedPermissions }); + } }; /** * Converts any activity to an Activity. diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/HasuraParsers.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/HasuraParsers.java index f5e60f0886..8307361d5d 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/HasuraParsers.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/HasuraParsers.java @@ -2,11 +2,8 @@ import gov.nasa.jpl.aerie.json.JsonParser; import gov.nasa.jpl.aerie.merlin.driver.SerializedActivity; -import gov.nasa.jpl.aerie.merlin.server.models.ActivityDirectiveId; import gov.nasa.jpl.aerie.merlin.server.models.HasuraAction; -import gov.nasa.jpl.aerie.merlin.server.models.HasuraActivityDirectiveEvent; import gov.nasa.jpl.aerie.merlin.server.models.HasuraMissionModelEvent; -import gov.nasa.jpl.aerie.merlin.server.models.PlanId; import java.util.Optional; @@ -24,7 +21,6 @@ import static gov.nasa.jpl.aerie.merlin.server.http.MerlinParsers.simulationDatasetIdP; import static gov.nasa.jpl.aerie.merlin.server.http.MerlinParsers.timestampP; import static gov.nasa.jpl.aerie.merlin.server.http.ProfileParsers.profileSetP; -import static gov.nasa.jpl.aerie.merlin.server.remotes.postgres.PostgresParsers.pgTimestampP; public abstract class HasuraParsers { private HasuraParsers() {} @@ -96,25 +92,6 @@ private static JsonParser> hasura untuple(missionModelId -> new HasuraMissionModelEvent(String.valueOf(missionModelId))), $ -> tuple(Long.parseLong($.missionModelId()))); - public static final JsonParser hasuraActivityDirectiveEventTriggerP - = productP - .field("event", productP - .field("data", productP - .field("new", productP - .field("plan_id", longP) - .field("id", longP) - .field("type", stringP) - .field("arguments", mapP(serializedValueP)) - .field("last_modified_arguments_at", pgTimestampP) - .rest()) - .rest()) - .rest()) - .rest() - .map( - untuple((planId, activityDirectiveId, type, arguments, argumentsModifiedTime) -> - new HasuraActivityDirectiveEvent(new PlanId(planId), new ActivityDirectiveId(activityDirectiveId), type, arguments, argumentsModifiedTime)), - $ -> tuple($.planId().id(), $.activityDirectiveId().id(), $.activityTypeName(), $.arguments(), $.argumentsModifiedTime())); - private static final JsonParser hasuraMissionModelArgumentsInputP = productP .field("missionModelId", stringP) diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java index cc887e65c3..dfc28b6582 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindings.java @@ -5,7 +5,6 @@ import gov.nasa.jpl.aerie.merlin.protocol.types.InstantiationException; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanDatasetException; import gov.nasa.jpl.aerie.merlin.server.exceptions.NoSuchPlanException; -import gov.nasa.jpl.aerie.merlin.server.models.ActivityDirectiveForValidation; import gov.nasa.jpl.aerie.merlin.server.services.ConstraintAction; import gov.nasa.jpl.aerie.merlin.server.models.HasuraAction; import gov.nasa.jpl.aerie.merlin.server.models.PlanId; @@ -32,7 +31,6 @@ import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraActivityActionP; import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraActivityBulkActionP; -import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraActivityDirectiveEventTriggerP; import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraConstraintsCodeAction; import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraConstraintsViolationsActionP; import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.hasuraUploadExternalDatasetActionP; @@ -94,7 +92,6 @@ public void apply(final Javalin javalin) { path("constraintViolations", () -> post(this::getConstraintViolations)); path("refreshModelParameters", () -> post(this::postRefreshModelParameters)); path("refreshActivityTypes", () -> post(this::postRefreshActivityTypes)); - path("refreshActivityValidations", () -> post(this::postRefreshActivityValidations)); path("refreshResourceTypes", () -> post(this::postRefreshResourceTypes)); path("validateActivityArguments", () -> post(this::validateActivityArguments)); path("validateModelArguments", () -> post(this::validateModelArguments)); @@ -143,30 +140,6 @@ private void postRefreshActivityTypes(final Context ctx) { } } - private void postRefreshActivityValidations(final Context ctx) { - try { - final var input = parseJson(ctx.body(), hasuraActivityDirectiveEventTriggerP); - final var planId = input.planId(); - final var serializedActivity = new SerializedActivity(input.activityTypeName(), input.arguments()); - final var activityDirective = new ActivityDirectiveForValidation(input.activityDirectiveId(), input.planId(), input.argumentsModifiedTime(), serializedActivity); - - final var plan = this.planService.getPlanForValidation(planId); - this.missionModelService.refreshActivityValidations(plan.missionModelId, activityDirective); - ctx.status(200); - } catch (final InvalidJsonException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidJsonException(ex).toString()); - } catch (final InvalidEntityException ex) { - ctx.status(400).result(ResponseSerializers.serializeInvalidEntityException(ex).toString()); - } catch (final InstantiationException ex) { - ctx.status(400) - .result(ResponseSerializers.serializeFailures(List.of(ex.getMessage())).toString()); - } catch (final NoSuchPlanException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchPlanException(ex).toString()); - } catch (final MissionModelService.NoSuchMissionModelException ex) { - ctx.status(404).result(ResponseSerializers.serializeNoSuchMissionModelException(ex).toString()); - } - } - private void postRefreshResourceTypes(Context ctx) { try { final var missionModelId = parseJson(ctx.body(), hasuraMissionModelEventTriggerP).missionModelId(); diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/models/HasuraActivityDirectiveEvent.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/models/HasuraActivityDirectiveEvent.java deleted file mode 100644 index 38373f576b..0000000000 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/models/HasuraActivityDirectiveEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.models; - -import java.util.Map; -import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; - -public record HasuraActivityDirectiveEvent -( - PlanId planId, - ActivityDirectiveId activityDirectiveId, - String activityTypeName, - Map arguments, - Timestamp argumentsModifiedTime -) { } diff --git a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java index 137a9c3ebb..3613a4c5e5 100644 --- a/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java +++ b/merlin-server/src/main/java/gov/nasa/jpl/aerie/merlin/server/services/LocalMissionModelService.java @@ -300,12 +300,13 @@ public void refreshActivityTypes(final String missionModelId) } @Override - public void refreshResourceTypes(final String missionModelId){ + public void refreshResourceTypes(final String missionModelId) + throws NoSuchMissionModelException { try { final var model = this.loadAndInstantiateMissionModel(missionModelId); this.missionModelRepository.updateResourceTypes(missionModelId, model.getResources()); - } catch (NoSuchMissionModelException | MissionModelRepository.NoSuchMissionModelException e) { - throw new RuntimeException(e); + } catch (MissionModelRepository.NoSuchMissionModelException e) { + throw new NoSuchMissionModelException(missionModelId); } } diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindingsTest.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindingsTest.java deleted file mode 100644 index 3c59b68a31..0000000000 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinBindingsTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.http; - -import gov.nasa.jpl.aerie.merlin.server.mocks.StubConstraintService; -import gov.nasa.jpl.aerie.merlin.server.mocks.StubMissionModelService; -import gov.nasa.jpl.aerie.merlin.server.mocks.StubPlanService; -import gov.nasa.jpl.aerie.merlin.server.services.ConstraintAction; -import gov.nasa.jpl.aerie.merlin.server.services.ConstraintService; -import gov.nasa.jpl.aerie.merlin.server.services.ConstraintsDSLCompilationService; -import gov.nasa.jpl.aerie.merlin.server.services.GenerateConstraintsLibAction; -import gov.nasa.jpl.aerie.merlin.server.services.GetSimulationResultsAction; -import gov.nasa.jpl.aerie.merlin.server.services.SynchronousSimulationAgent; -import gov.nasa.jpl.aerie.merlin.server.services.TypescriptCodeGenerationServiceAdapter; -import gov.nasa.jpl.aerie.merlin.server.services.UncachedSimulationService; -import gov.nasa.jpl.aerie.permissions.PermissionsService; -import gov.nasa.jpl.aerie.permissions.gql.GraphQLPermissionsService; -import io.javalin.Javalin; -import io.javalin.plugin.bundled.CorsPluginConfig; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -import static org.assertj.core.api.Assertions.assertThat; - -public final class MerlinBindingsTest { - private static Javalin SERVER = null; - - @BeforeAll - public static void setupServer() { - final var planApp = new StubPlanService(); - final var missionModelApp = new StubMissionModelService(); - - final var typescriptCodeGenerationService = new TypescriptCodeGenerationServiceAdapter(missionModelApp, planApp); - - final ConstraintsDSLCompilationService constraintsDSLCompilationService; - try { - constraintsDSLCompilationService = new ConstraintsDSLCompilationService(typescriptCodeGenerationService); - } catch (IOException e) { - throw new Error("Failed to start ConstraintsDSLCompilationService", e); - } - - Runtime.getRuntime().addShutdownHook(new Thread(constraintsDSLCompilationService::close)); - - final var simulationService = new UncachedSimulationService(new SynchronousSimulationAgent(planApp, missionModelApp, 5000)); - final var simulationAction = new GetSimulationResultsAction( - planApp, - simulationService - ); - - final var generateConstraintsLibAction = new GenerateConstraintsLibAction(typescriptCodeGenerationService); - final var constraintService = new StubConstraintService(); - final var constraintAction = new ConstraintAction(constraintsDSLCompilationService, constraintService, planApp, missionModelApp, simulationService); - final var permissionsService = new PermissionsService(new GraphQLPermissionsService(URI.create("localhost:8080/v1/graphql"), "aerie")); - - SERVER = Javalin.create(config -> { - config.showJavalinBanner = false; - config.plugins.enableCors(cors -> cors.add(CorsPluginConfig::anyHost)); - config.plugins.register( - new MerlinBindings( - missionModelApp, - planApp, - simulationAction, - generateConstraintsLibAction, - constraintAction, - permissionsService - )); - }); - - SERVER.start(54321); // Use likely unused port to avoid clash with any currently hosted port 80 services - } - - @AfterAll - public static void shutdownServer() { - SERVER.stop(); - } - - private final URI baseUri = URI.create("http://localhost:" + SERVER.port()); - private final HttpClient rawHttpClient = HttpClient.newHttpClient(); - - @Test - public void shouldEnableCors() throws IOException, InterruptedException { - // GIVEN - final String origin = "http://localhost"; - - // WHEN - final HttpRequest request = HttpRequest.newBuilder() - .uri(baseUri.resolve("/plans")) - .header("Origin", origin) - .GET() - .build(); - - final HttpResponse response = rawHttpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - // THEN - assertThat(response.headers().allValues("Access-Control-Allow-Origin")).isNotEmpty(); - } -} diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsersTest.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsersTest.java index fc7bbb0fa7..5bad8ec036 100644 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsersTest.java +++ b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/http/MerlinParsersTest.java @@ -3,19 +3,13 @@ import gov.nasa.jpl.aerie.json.JsonParseResult; import gov.nasa.jpl.aerie.json.JsonParser; import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; -import gov.nasa.jpl.aerie.merlin.server.models.ActivityDirectiveId; import gov.nasa.jpl.aerie.merlin.server.models.HasuraAction; -import gov.nasa.jpl.aerie.merlin.server.models.HasuraActivityDirectiveEvent; import gov.nasa.jpl.aerie.merlin.server.models.HasuraMissionModelEvent; -import gov.nasa.jpl.aerie.merlin.server.models.PlanId; -import gov.nasa.jpl.aerie.merlin.server.models.Timestamp; import org.junit.jupiter.api.Test; import javax.json.Json; import javax.json.JsonValue; -import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.Objects; import static gov.nasa.jpl.aerie.json.BasicParsers.listP; @@ -24,7 +18,6 @@ import static gov.nasa.jpl.aerie.merlin.driver.json.SerializedValueJsonParser.serializedValueP; import static gov.nasa.jpl.aerie.merlin.server.http.HasuraParsers.*; import static gov.nasa.jpl.aerie.merlin.server.http.MerlinParsersTest.NestedLists.nestedList; -import static gov.nasa.jpl.aerie.merlin.server.remotes.postgres.PostgresParsers.pgTimestampP; import static org.assertj.core.api.Assertions.assertThat; public final class MerlinParsersTest { @@ -172,34 +165,4 @@ public void testHasuraMissionModelEventParser() { assertThat(hasuraMissionModelEventTriggerP.parse(json).getSuccessOrThrow()).isEqualTo(expected); } - - @Test - public void testHasuraActivityDirectiveEventParser() { - final var now = new Timestamp(Instant.now()); - - final var json = Json - .createObjectBuilder() - .add("event", Json - .createObjectBuilder() - .add("data", Json - .createObjectBuilder() - .add("new", Json - .createObjectBuilder() - .add("plan_id", 1) - .add("id", 1) - .add("type", "Test") - .add("arguments", Json.createObjectBuilder().add("A", 42).build()) - .add("last_modified_arguments_at", pgTimestampP.unparse(now)) - .build()) - .add("old", JsonValue.NULL) - .build()) - .add("op", "INSERT") - .build()) - .add("id", "8907a407-28a5-440a-8de6-240b80c58a8b") - .build(); - - final var expected = new HasuraActivityDirectiveEvent(new PlanId(1), new ActivityDirectiveId(1), "Test", Map.of("A", SerializedValue.of(42)), now); - - assertThat(hasuraActivityDirectiveEventTriggerP.parse(json).getSuccessOrThrow()).isEqualTo(expected); - } } diff --git a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubConstraintService.java b/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubConstraintService.java deleted file mode 100644 index 25a0679600..0000000000 --- a/merlin-server/src/test/java/gov/nasa/jpl/aerie/merlin/server/mocks/StubConstraintService.java +++ /dev/null @@ -1,27 +0,0 @@ -package gov.nasa.jpl.aerie.merlin.server.mocks; - -import gov.nasa.jpl.aerie.constraints.model.Violation; -import gov.nasa.jpl.aerie.merlin.server.models.Constraint; -import gov.nasa.jpl.aerie.merlin.server.models.PlanId; -import gov.nasa.jpl.aerie.merlin.server.models.SimulationDatasetId; -import gov.nasa.jpl.aerie.merlin.server.remotes.postgres.ConstraintRunRecord; -import gov.nasa.jpl.aerie.merlin.server.services.ConstraintService; - -import java.util.List; -import java.util.Map; - -public class StubConstraintService implements ConstraintService { - @Override - public void createConstraintRuns( - final Map constraintMap, - final Map violations, - final SimulationDatasetId simulationDatasetId) - { - - } - - @Override - public Map getValidConstraintRuns(final List constraints, SimulationDatasetId simulationDatasetId) { - return null; - } -} diff --git a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java index 8789407ef5..20ae254bab 100644 --- a/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java +++ b/permissions/src/main/java/gov/nasa/jpl/aerie/permissions/exceptions/ExceptionSerializers.java @@ -15,7 +15,7 @@ public static JsonValue serializeNoSuchPlanException(final NoSuchPlanException e public static JsonValue serializeNoSuchSchedulingSpecificationException(final NoSuchSchedulingSpecificationException ex) { return Json.createObjectBuilder() - .add("message", "no such plan") + .add("message", "no such scheduling specification") .add("plan_id", ex.id.id()) .build(); }