Skip to content

Commit

Permalink
feat: LTI proctoring integrity check (#116)
Browse files Browse the repository at this point in the history
Adds a check to ensure proctoring software is running if page is loaded in state monitored by a tool.
  • Loading branch information
zacharis278 authored Sep 11, 2023
1 parent b8ed1fe commit 9ac3d78
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 34 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
Expand Down
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const ONBOARDING_ERRORS = [
export const IS_STARTED_STATUS = (status) => [ExamStatus.STARTED, ExamStatus.READY_TO_SUBMIT].includes(status);
export const IS_INCOMPLETE_STATUS = (status) => INCOMPLETE_STATUSES.includes(status);
export const IS_ONBOARDING_ERROR = (status) => ONBOARDING_ERRORS.includes(status);
// if the exam is proctored we expect the software to be monitoring these states
export const IS_PROCTORED_STATUS = (status) => IS_STARTED_STATUS(status) || status === ExamStatus.READY_TO_START;

// Available actions are taken from
// https://github.com/edx/edx-proctoring/blob/1444ca40a43869fb4e2731cea4862888c5b5f286/edx_proctoring/views.py#L765
Expand Down
4 changes: 2 additions & 2 deletions src/data/__factories__/attempt.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Factory.define('attempt')
attempt_status: 'started',
in_timed_exam: true,
taking_as_proctored: false,
exam_type: 'a timed exam',
exam_display_name: 'timed',
exam_display_name: 'a timed exam',
exam_type: 'timed',
exam_url_path: 'http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123',
time_remaining_seconds: 1799.9,
course_id: 'course-v1:test+special+exam',
Expand Down
48 changes: 24 additions & 24 deletions src/data/__snapshots__/redux.test.jsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -35,9 +35,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand Down Expand Up @@ -99,9 +99,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand Down Expand Up @@ -190,9 +190,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -209,9 +209,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -228,9 +228,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -253,9 +253,9 @@ Object {
"attempt_status": "created",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand Down Expand Up @@ -321,9 +321,9 @@ Object {
"attempt_status": "created",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand Down Expand Up @@ -383,9 +383,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -402,9 +402,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -421,9 +421,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand All @@ -440,9 +440,9 @@ Object {
"attempt_status": "started",
"course_id": "course-v1:test+special+exam",
"desktop_application_js_url": "",
"exam_display_name": "timed",
"exam_display_name": "a timed exam",
"exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1",
"exam_type": "a timed exam",
"exam_type": "timed",
"exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123",
"in_timed_exam": true,
"software_download_url": "",
Expand Down
16 changes: 16 additions & 0 deletions src/data/messages/proctorio.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@
* vendor-specific integrations long term. As of now these events
* will trigger on ANY lti integration, not just Proctorio.
*/
export async function checkAppStatus() {
return new Promise((resolve, reject) => {
const handleResponse = event => {
if (event.origin === 'https://getproctorio.com') {
window.removeEventListener('message', handleResponse);
if (event?.data?.active) {
resolve();
}
reject();
}
};
window.addEventListener('message', handleResponse);
window.top.postMessage(['proctorio_status'], '*');
});
}

export function notifyStartExam() {
window.top.postMessage(
['exam_state_change', 'exam_take'],
Expand Down
96 changes: 95 additions & 1 deletion src/data/redux.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as thunks from './thunks';
import executeThunk from '../utils';

import { initializeTestStore, initializeMockApp, initializeTestConfig } from '../setupTest';
import { ExamStatus } from '../constants';
import { ExamStatus, ExamType } from '../constants';

const BASE_API_URL = '/api/edx_proctoring/v1/proctored_exam/attempt';

Expand Down Expand Up @@ -1095,4 +1095,98 @@ describe('Data layer integration tests', () => {
expect(state.examState.examAccessToken.exam_access_token).toBe('');
});
});

describe('Test checkExamEntry', () => {
const mockPostMessage = jest.fn();
const mockAddEventListener = jest.fn();

beforeEach(() => {
windowSpy.mockImplementation(() => ({
top: {
postMessage: mockPostMessage,
},
addEventListener: mockAddEventListener,
removeEventListener: jest.fn(),
}));
});

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

it('should check application status for LTI proctored exams in a proctored state', async () => {
const proctoredAttempt = Factory.build('attempt', { attempt_status: ExamStatus.READY_TO_START, exam_type: ExamType.PROCTORED });
const proctoredExam = Factory.build('exam', { type: ExamType.PROCTORED, attempt: proctoredAttempt });
await initWithExamAttempt(proctoredExam, proctoredAttempt);

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);
await new Promise(process.nextTick);
expect(mockPostMessage).toHaveBeenCalled();
});

it('should not check application status for non-LTI proctored exams', async () => {
const proctoredAttempt = Factory.build('attempt', { attempt_status: ExamStatus.READY_TO_START, exam_type: ExamType.PROCTORED, use_legacy_attempt_api: true });
const proctoredExam = Factory.build('exam', { type: ExamType.PROCTORED, attempt: proctoredAttempt });
await initWithExamAttempt(proctoredExam, proctoredAttempt);

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);
await new Promise(process.nextTick);
expect(mockPostMessage).not.toHaveBeenCalled();
});

it('should not check application status for exams in a non-proctored state', async () => {
const nonProctoredAttempt = Factory.build('attempt', { attempt_status: ExamStatus.CREATED });
const proctoredExam = Factory.build('exam', { attempt: nonProctoredAttempt });
await initWithExamAttempt(proctoredExam, nonProctoredAttempt);

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);
await new Promise(process.nextTick);
expect(mockPostMessage).not.toHaveBeenCalled();
});

it('should not check timed exams', async () => {
// default exam is timed
await initWithExamAttempt();

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);
await new Promise(process.nextTick);
expect(mockPostMessage).not.toHaveBeenCalled();
});

it('should transition to error state if check fails', async () => {
const proctoredAttempt = Factory.build('attempt', { attempt_status: ExamStatus.READY_TO_START, exam_type: ExamType.PROCTORED });
const proctoredExam = Factory.build('exam', { type: ExamType.PROCTORED, attempt: proctoredAttempt });
await initWithExamAttempt(proctoredExam, proctoredAttempt);

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);

await new Promise(process.nextTick);
const handleResponseCb = mockAddEventListener.mock.calls[0][1];
axiosMock.onPut(`${createUpdateAttemptURL}/${proctoredAttempt.attempt_id}`).reply(200, { exam_attempt_id: proctoredAttempt.attempt_id });
handleResponseCb({ origin: 'https://getproctorio.com', data: { active: false } });

await new Promise(process.nextTick);
const request = axiosMock.history.put[0];
expect(request.data).toEqual(JSON.stringify({
action: 'error',
detail: 'exam reentry disallowed',
}));
});

it('should not transition to error state if check succeeds', async () => {
const proctoredAttempt = Factory.build('attempt', { attempt_status: ExamStatus.READY_TO_START, exam_type: ExamType.PROCTORED });
const proctoredExam = Factory.build('exam', { type: ExamType.PROCTORED, attempt: proctoredAttempt });
await initWithExamAttempt(proctoredExam, proctoredAttempt);

await executeThunk(thunks.checkExamEntry(), store.dispatch, store.getState);

await new Promise(process.nextTick);
const handleResponseCb = mockAddEventListener.mock.calls[0][1];
handleResponseCb({ origin: 'https://getproctorio.com', data: { active: true } });

await new Promise(process.nextTick);
expect(axiosMock.history.put.length).toBe(0);
});
});
});
37 changes: 35 additions & 2 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ import {
setApiError,
setAllowProctoringOptOut,
} from './slice';
import { ExamStatus } from '../constants';
import { ExamStatus, ExamType, IS_PROCTORED_STATUS } from '../constants';
import { workerPromiseForEventNames, pingApplication } from './messages/handlers';
import actionToMessageTypesMap from './messages/constants';
import { notifyEndExam, notifyStartExam } from './messages/proctorio';
import { checkAppStatus, notifyEndExam, notifyStartExam } from './messages/proctorio';

function handleAPIError(error, dispatch) {
const { message, detail } = error;
Expand Down Expand Up @@ -528,3 +528,36 @@ export function getAllowProctoringOptOut(allowProctoringOptOut) {
dispatch(setAllowProctoringOptOut({ allowProctoringOptOut }));
};
}

/**
* Check if we are allowed to enter an exam where proctoring has started.
* There is no support for reentry with LTI. The exam must be completed
* in the proctored window. If a non-proctored window is opened, the exam will
* be ended with an error.
*
* This check is necessary to prevent using a second browser to access the exam
* content unproctored.
*/
export function checkExamEntry() {
return async (dispatch, getState) => {
const { exam } = getState().examState;
// Check only applies to LTI exams
if (
!exam.attempt
|| exam.attempt.exam_type !== ExamType.PROCTORED
|| exam.attempt.use_legacy_attempt_api
) { return; }

if (IS_PROCTORED_STATUS(exam.attempt.attempt_status)) {
Promise.race([
checkAppStatus(),
new Promise((resolve, reject) => {
setTimeout(() => reject(), EXAM_START_TIMEOUT_MILLISECONDS);
}),
]).catch(() => {
dispatch(setApiError({ errorMsg: 'Something has gone wrong with your exam. Proctoring application not detected.' }));
updateAttemptAfter(exam.course_id, exam.content_id, endExamWithFailure(exam.attempt.attempt_id, 'exam reentry disallowed'))(dispatch);
});
}
};
}
3 changes: 2 additions & 1 deletion src/exam/ExamWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ const ExamWrapper = ({ children, ...props }) => {
originalUserIsStaff,
canAccessProctoredExams,
} = props;
const { getExamAttemptsData, getAllowProctoringOptOut } = state;
const { getExamAttemptsData, getAllowProctoringOptOut, checkExamEntry } = state;
const loadInitialData = async () => {
await getExamAttemptsData(courseId, sequence.id);
await getAllowProctoringOptOut(sequence.allowProctoringOptOut);
await checkExamEntry();
};

const isGated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
Expand Down
13 changes: 10 additions & 3 deletions src/instructions/Instructions.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,8 +793,12 @@ describe('SequenceExamWrapper', () => {
});

it('Initiates an LTI launch in a new window when the user clicks the System Check button', async () => {
const windowSpy = jest.spyOn(window, 'open');
windowSpy.mockImplementation(() => ({}));
const { location } = window;
delete window.location;
const mockAssign = jest.fn();
window.location = {
assign: mockAssign,
};
store.getState = () => ({
examState: Factory.build('examState', {
activeAttempt: {},
Expand Down Expand Up @@ -825,13 +829,16 @@ describe('SequenceExamWrapper', () => {
{ store },
);
fireEvent.click(screen.getByText('Start System Check'));
await waitFor(() => { expect(windowSpy).toHaveBeenCalledWith('http://localhost:18740/lti/start_proctoring/4321', '_blank'); });
await waitFor(() => { expect(mockAssign).toHaveBeenCalledWith('http://localhost:18740/lti/start_proctoring/4321'); });
expect(softwareDownloadAttempt).toHaveBeenCalledWith(4321, false);

// also validate start button works
pollExamAttempt.mockReturnValue(Promise.resolve({ status: ExamStatus.READY_TO_START }));
fireEvent.click(screen.getByText('Start Exam'));
await waitFor(() => { expect(getExamAttemptsData).toHaveBeenCalled(); });

// restore window.location
window.location = location;
});

it('Shows correct download instructions for legacy rest provider if attempt status is created', () => {
Expand Down
Loading

0 comments on commit 9ac3d78

Please sign in to comment.