Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ import {
getNotebookName,
getTrustedOrigins,
normalizeBaseUrl,
parseMarkusId
parseMarkusId,
submitWithSessionRetry
} from '../jupyterlab-markus-extension';
import { invalidateSession } from '../session';

const mockGetBaseUrl = PageConfig.getBaseUrl as jest.Mock;
const mockGetToken = PageConfig.getToken as jest.Mock;
Expand Down Expand Up @@ -333,20 +335,20 @@ describe('buildSubmitPayload', () => {

it('throws when the notebook path is unavailable', () => {
const panel = makePanel({ path: '' });
expect(() => buildSubmitPayload(panel, markus)).toThrow('Could not determine notebook path.');
expect(() => buildSubmitPayload(panel, markus, 'session-token')).toThrow('Could not determine notebook path.');
});

it('throws when no Jupyter token is available', () => {
mockGetToken.mockReturnValue('');
const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' });

expect(() => buildSubmitPayload(panel, markus)).toThrow('No Jupyter token available.');
expect(() => buildSubmitPayload(panel, markus, 'session-token')).toThrow('No Jupyter token available.');
});

it('assembles the full payload from the panel, markus metadata, and PageConfig', () => {
it('assembles the full payload from the panel, markus metadata, PageConfig, and session token', () => {
const panel = makePanel({ path: 'nested/demo.ipynb', contentsModelName: 'demo.ipynb' });

expect(buildSubmitPayload(panel, markus)).toEqual({
expect(buildSubmitPayload(panel, markus, 'session-token')).toEqual({
notebook_path: 'nested/demo.ipynb',
course_id: 1,
course: undefined,
Expand All @@ -355,7 +357,114 @@ describe('buildSubmitPayload', () => {
jupyter: {
base_url: 'http://localhost:8888/',
token: 'test-token'
}
},
session_token: 'session-token'
});
});
});

describe('submitWithSessionRetry', () => {
const markus = {
url: 'http://retry.example.com/',
course_id: 1,
assignment_id: 2
};

let mockFetch: jest.Mock;

beforeEach(() => {
mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/');
mockGetToken.mockReset().mockReturnValue('test-token');
mockFetch = jest.fn();
(global as any).fetch = mockFetch;
invalidateSession(markus);
});

function authResponse(sessionToken: string): { ok: true; status: 200; text: () => Promise<string> } {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
status: 'success',
session_token: sessionToken,
expires_at: new Date(Date.now() + 60_000).toISOString()
})
};
}

function submitSuccess(): { ok: true; status: 200; text: () => Promise<string> } {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'success', submitted_file: 'demo.ipynb' })
};
}

function submitUnauthorized(): { ok: false; status: 401; text: () => Promise<string> } {
return {
ok: false,
status: 401,
text: async () => JSON.stringify({ status: 'error', message: 'Session expired.', error_class: 'IdentityError' })
};
}

it('authenticates then submits on the happy path', async () => {
const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' });
mockFetch
.mockResolvedValueOnce(authResponse('sess-1'))
.mockResolvedValueOnce(submitSuccess());

const result = await submitWithSessionRetry(panel, markus);

expect(result.submitted_file).toBe('demo.ipynb');
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('re-authenticates and retries exactly once on a 401, succeeding the second time', async () => {
const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' });
mockFetch
.mockResolvedValueOnce(authResponse('sess-1'))
.mockResolvedValueOnce(submitUnauthorized())
.mockResolvedValueOnce(authResponse('sess-2'))
.mockResolvedValueOnce(submitSuccess());

const result = await submitWithSessionRetry(panel, markus);

expect(result.submitted_file).toBe('demo.ipynb');
expect(mockFetch).toHaveBeenCalledTimes(4);

const secondSubmitBody = JSON.parse((mockFetch.mock.calls[3][1] as RequestInit).body as string);
expect(secondSubmitBody.session_token).toBe('sess-2');
});

it('propagates the error if the retried submit also fails with a 401', async () => {
const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' });
mockFetch
.mockResolvedValueOnce(authResponse('sess-1'))
.mockResolvedValueOnce(submitUnauthorized())
.mockResolvedValueOnce(authResponse('sess-2'))
.mockResolvedValueOnce(submitUnauthorized());

await expect(submitWithSessionRetry(panel, markus)).rejects.toMatchObject({
name: 'MarkUsServerError',
status: 401
});
expect(mockFetch).toHaveBeenCalledTimes(4);
});

it('does not retry on a non-401 failure', async () => {
const panel = makePanel({ path: 'demo.ipynb', contentsModelName: 'demo.ipynb' });
mockFetch.mockResolvedValueOnce(authResponse('sess-1')).mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => JSON.stringify({ status: 'error', message: 'Not a student in this course.' })
});

await expect(submitWithSessionRetry(panel, markus)).rejects.toMatchObject({
name: 'MarkUsServerError',
status: 403
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
170 changes: 170 additions & 0 deletions jupyterlab-markus-extension/src/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// See the top of jupyterlab-markus-extension.test.ts for why PageConfig is
// mocked rather than imported for real.
jest.mock('@jupyterlab/coreutils', () => ({
PageConfig: {
getBaseUrl: jest.fn(),
getToken: jest.fn()
}
}));

import { PageConfig } from '@jupyterlab/coreutils';

import { authenticateWithMarkUs, getOrCreateSession, invalidateSession, MarkUsServerError } from '../session';

const mockGetBaseUrl = PageConfig.getBaseUrl as jest.Mock;
const mockGetToken = PageConfig.getToken as jest.Mock;

describe('authenticateWithMarkUs', () => {
const markus = {
url: 'http://localhost:3000/',
course_id: 1,
assignment_id: 2
};

let mockFetch: jest.Mock;

beforeEach(() => {
mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/');
mockGetToken.mockReset().mockReturnValue('test-token');
mockFetch = jest.fn();
(global as any).fetch = mockFetch;
});

it('posts the jupyter base_url/token and returns the parsed session response', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
status: 'success',
session_token: 'sess-abc',
expires_at: '2026-08-25T12:15:00Z',
markus_user_name: 'c9user'
})
});

const result = await authenticateWithMarkUs(markus);

expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3000/jupyter/authenticate',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
jupyter: { base_url: 'http://localhost:8888/', token: 'test-token' }
})
})
);
expect(result).toEqual({
status: 'success',
session_token: 'sess-abc',
expires_at: '2026-08-25T12:15:00Z',
markus_user_name: 'c9user'
});
});

it('throws a MarkUsServerError carrying the status and server message on failure', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: async () =>
JSON.stringify({ status: 'error', message: 'Invalid Jupyter token.', error_class: 'IdentityError' })
});

let caught: unknown;
try {
await authenticateWithMarkUs(markus);
} catch (error) {
caught = error;
}

expect(caught).toBeInstanceOf(MarkUsServerError);
expect((caught as MarkUsServerError).status).toBe(401);
expect((caught as MarkUsServerError).message).toContain('Invalid Jupyter token.');
});

it('throws when no Jupyter token is available', async () => {
mockGetToken.mockReturnValue('');
await expect(authenticateWithMarkUs(markus)).rejects.toThrow('No Jupyter token available.');
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('getOrCreateSession / invalidateSession', () => {
// A distinct origin per describe block keeps the module-level session
// cache from leaking state between suites.
const markus = {
url: 'http://session-cache.example.com/',
course_id: 1,
assignment_id: 2
};

let mockFetch: jest.Mock;

beforeEach(() => {
mockGetBaseUrl.mockReset().mockReturnValue('http://localhost:8888/');
mockGetToken.mockReset().mockReturnValue('test-token');
mockFetch = jest.fn();
(global as any).fetch = mockFetch;
invalidateSession(markus);
});

function mockAuthSuccess(sessionToken: string, expiresAt: string): void {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'success', session_token: sessionToken, expires_at: expiresAt })
});
}

it('authenticates once and reuses the cached token within its TTL', async () => {
mockAuthSuccess('sess-1', new Date(Date.now() + 60_000).toISOString());

const first = await getOrCreateSession(markus);
const second = await getOrCreateSession(markus);

expect(first).toBe('sess-1');
expect(second).toBe('sess-1');
expect(mockFetch).toHaveBeenCalledTimes(1);
});

it('re-authenticates once the cached token is within the expiry safety margin', async () => {
mockAuthSuccess('sess-1', new Date(Date.now() + 5_000).toISOString());
mockAuthSuccess('sess-2', new Date(Date.now() + 60_000).toISOString());

const first = await getOrCreateSession(markus);
const second = await getOrCreateSession(markus);

expect(first).toBe('sess-1');
expect(second).toBe('sess-2');
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('re-authenticates after invalidateSession is called', async () => {
mockAuthSuccess('sess-1', new Date(Date.now() + 60_000).toISOString());
mockAuthSuccess('sess-2', new Date(Date.now() + 60_000).toISOString());

const first = await getOrCreateSession(markus);
invalidateSession(markus);
const second = await getOrCreateSession(markus);

expect(first).toBe('sess-1');
expect(second).toBe('sess-2');
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('throws when the response is missing session_token or expires_at', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'success' })
});

await expect(getOrCreateSession(markus)).rejects.toThrow(/missing "session_token" or "expires_at"/);
});

it('throws when expires_at cannot be parsed', async () => {
mockAuthSuccess('sess-1', 'not-a-date');

await expect(getOrCreateSession(markus)).rejects.toThrow(/invalid "expires_at" value/);
});
});
Loading