richie-education 3.2.2-dev27 → 3.2.2-dev30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jest.config.js CHANGED
@@ -15,7 +15,17 @@ module.exports = {
15
15
  },
16
16
  resolver: '<rootDir>/jest/resolver.js',
17
17
  transformIgnorePatterns: [
18
- 'node_modules/(?!(react-intl|lodash-es|@hookform/resolvers|query-string|decode-uri-component|split-on-first|filter-obj|@openfun/cunningham-react)/)',
18
+ 'node_modules/(?!(' +
19
+ 'react-intl' +
20
+ '|lodash-es' +
21
+ '|@hookform/resolvers' +
22
+ '|query-string' +
23
+ '|decode-uri-component' +
24
+ '|split-on-first' +
25
+ '|filter-obj' +
26
+ '|@openfun/cunningham-react' +
27
+ '|keycloak-js' +
28
+ ')/)',
19
29
  ],
20
30
  globals: {
21
31
  RICHIE_VERSION: 'test',
@@ -0,0 +1,126 @@
1
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
2
+ import API from './keycloak';
3
+
4
+ const mockKeycloakInit = jest.fn().mockResolvedValue(true);
5
+ const mockKeycloakLogout = jest.fn().mockResolvedValue(undefined);
6
+ const mockKeycloakLogin = jest.fn().mockResolvedValue(undefined);
7
+ const mockKeycloakLoadUserProfile = jest.fn();
8
+
9
+ jest.mock('keycloak-js', () => {
10
+ return jest.fn().mockImplementation(() => ({
11
+ init: mockKeycloakInit,
12
+ logout: mockKeycloakLogout,
13
+ login: mockKeycloakLogin,
14
+ loadUserProfile: mockKeycloakLoadUserProfile,
15
+ }));
16
+ });
17
+
18
+ jest.mock('utils/indirection/window', () => ({
19
+ location: {
20
+ origin: 'https://richie.test',
21
+ pathname: '/courses/test-course/',
22
+ },
23
+ }));
24
+
25
+ jest.mock('utils/context', () => ({
26
+ __esModule: true,
27
+ default: mockRichieContextFactory({
28
+ authentication: {
29
+ backend: 'keycloak',
30
+ endpoint: 'https://keycloak.test/auth',
31
+ client_id: 'richie-client',
32
+ realm: 'richie-realm',
33
+ auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
34
+ },
35
+ }).one(),
36
+ }));
37
+
38
+ describe('Keycloak API', () => {
39
+ const authConfig = {
40
+ backend: 'keycloak',
41
+ endpoint: 'https://keycloak.test/auth',
42
+ client_id: 'richie-client',
43
+ realm: 'richie-realm',
44
+ auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
45
+ registration_url:
46
+ 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/registrations',
47
+ };
48
+
49
+ let keycloakApi: ReturnType<typeof API>;
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ keycloakApi = API(authConfig);
54
+ });
55
+
56
+ describe('user.me', () => {
57
+ it('returns null when loadUserProfile fails', async () => {
58
+ mockKeycloakLoadUserProfile.mockRejectedValueOnce(new Error('Not authenticated'));
59
+ const response = await keycloakApi.user.me();
60
+ expect(response).toBeNull();
61
+ });
62
+
63
+ it('returns user when loadUserProfile succeeds', async () => {
64
+ mockKeycloakLoadUserProfile.mockResolvedValueOnce({
65
+ firstName: 'John',
66
+ lastName: 'Doe',
67
+ email: 'johndoe@example.com',
68
+ });
69
+
70
+ const response = await keycloakApi.user.me();
71
+ expect(response).toEqual({
72
+ username: 'John Doe',
73
+ email: 'johndoe@example.com',
74
+ });
75
+ });
76
+ });
77
+
78
+ describe('user.login', () => {
79
+ it('calls keycloak.login with correct redirect URI', async () => {
80
+ await keycloakApi.user.login();
81
+
82
+ expect(mockKeycloakLogin).toHaveBeenCalledWith({
83
+ redirectUri: 'https://richie.test/courses/test-course/',
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('user.register', () => {
89
+ it('calls keycloak.login with register action', async () => {
90
+ await keycloakApi.user.register();
91
+
92
+ expect(mockKeycloakLogin).toHaveBeenCalledWith({
93
+ redirectUri: 'https://richie.test/courses/test-course/',
94
+ action: 'REGISTER',
95
+ });
96
+ });
97
+ });
98
+
99
+ describe('user.logout', () => {
100
+ it('calls keycloak.logout with correct redirect URI', async () => {
101
+ await keycloakApi.user.logout();
102
+
103
+ expect(mockKeycloakLogout).toHaveBeenCalledWith({
104
+ redirectUri: 'https://richie.test/courses/test-course/',
105
+ });
106
+ });
107
+ });
108
+
109
+ describe('Keycloak initialization', () => {
110
+ it('initializes keycloak with correct configuration', () => {
111
+ const Keycloak = require('keycloak-js');
112
+
113
+ expect(Keycloak).toHaveBeenCalledWith({
114
+ url: 'https://keycloak.test/auth',
115
+ realm: 'richie-realm',
116
+ clientId: 'richie-client',
117
+ });
118
+
119
+ expect(mockKeycloakInit).toHaveBeenCalledWith({
120
+ checkLoginIframe: false,
121
+ flow: 'implicit',
122
+ token: undefined,
123
+ });
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,55 @@
1
+ import Keycloak from 'keycloak-js';
2
+ import { AuthenticationBackend } from 'types/commonDataProps';
3
+ import { APIAuthentication } from 'types/api';
4
+ import { location } from 'utils/indirection/window';
5
+ import { handle } from 'utils/errors/handle';
6
+
7
+ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
8
+ const keycloak = new Keycloak({
9
+ url: APIConf.endpoint,
10
+ realm: APIConf.realm!,
11
+ clientId: APIConf.client_id!,
12
+ });
13
+ keycloak.init({
14
+ checkLoginIframe: false,
15
+ flow: 'implicit',
16
+ token: APIConf.token!,
17
+ });
18
+
19
+ const getRedirectUri = () => {
20
+ return `${location.origin}${location.pathname}`;
21
+ };
22
+
23
+ return {
24
+ user: {
25
+ me: async () => {
26
+ return keycloak
27
+ .loadUserProfile()
28
+ .then((userProfile) => {
29
+ return {
30
+ username: `${userProfile.firstName} ${userProfile.lastName}`,
31
+ email: userProfile.email,
32
+ };
33
+ })
34
+ .catch((error) => {
35
+ handle(error);
36
+ return null;
37
+ });
38
+ },
39
+
40
+ login: async () => {
41
+ await keycloak.login({ redirectUri: getRedirectUri() });
42
+ },
43
+
44
+ register: async () => {
45
+ await keycloak.login({ redirectUri: getRedirectUri(), action: 'REGISTER' });
46
+ },
47
+
48
+ logout: async () => {
49
+ await keycloak.logout({ redirectUri: getRedirectUri() });
50
+ },
51
+ },
52
+ };
53
+ };
54
+
55
+ export default API;
@@ -11,6 +11,7 @@ import { Nullable } from 'types/utils';
11
11
  import context from 'utils/context';
12
12
  import { APIAuthentication, APIBackend } from 'types/api';
13
13
  import DummyApiInterface from './lms/dummy';
14
+ import KeycloakApiInterface from './auth/keycloak';
14
15
  import OpenEdxDogwoodApiInterface from './lms/openedx-dogwood';
15
16
  import OpenEdxHawthornApiInterface from './lms/openedx-hawthorn';
16
17
  import OpenEdxFonzieApiInterface from './lms/openedx-fonzie';
@@ -22,6 +23,8 @@ const AuthenticationAPIHandler = (): Nullable<APIAuthentication> => {
22
23
  switch (AUTHENTICATION.backend) {
23
24
  case APIBackend.DUMMY:
24
25
  return DummyApiInterface(AUTHENTICATION).user;
26
+ case APIBackend.KEYCLOAK:
27
+ return KeycloakApiInterface(AUTHENTICATION).user;
25
28
  case APIBackend.OPENEDX_DOGWOOD:
26
29
  return OpenEdxDogwoodApiInterface(AUTHENTICATION).user;
27
30
  case APIBackend.OPENEDX_HAWTHORN:
package/js/types/api.ts CHANGED
@@ -60,6 +60,7 @@ export enum APIBackend {
60
60
  DUMMY = 'dummy',
61
61
  FONZIE = 'fonzie',
62
62
  JOANIE = 'joanie',
63
+ KEYCLOAK = 'keycloak',
63
64
  OPENEDX_DOGWOOD = 'openedx-dogwood',
64
65
  OPENEDX_HAWTHORN = 'openedx-hawthorn',
65
66
  }
@@ -14,6 +14,17 @@ export interface LMSBackend {
14
14
  export interface AuthenticationBackend {
15
15
  backend: string;
16
16
  endpoint: string;
17
+ client_id?: string;
18
+ realm?: string;
19
+ token?: string;
20
+ auth_url?: string;
21
+ registration_url?: string;
22
+ user_info_url?: string;
23
+ logout_url?: string;
24
+ user?: {
25
+ username: string;
26
+ email: string;
27
+ };
17
28
  }
18
29
 
19
30
  enum FEATURES {
@@ -7,6 +7,8 @@ import {
7
7
  DashboardSidebarProps,
8
8
  } from 'widgets/Dashboard/components/DashboardSidebar';
9
9
  import { useSession } from 'contexts/SessionContext';
10
+ import { APIBackend } from 'types/api';
11
+ import context from 'utils/context';
10
12
  import { UserHelper } from 'utils/UserHelper';
11
13
 
12
14
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
@@ -30,15 +32,18 @@ export const LearnerDashboardSidebar = (props: Partial<DashboardSidebarProps>) =
30
32
 
31
33
  const getRouteLabel = getDashboardRouteLabel(intl);
32
34
 
35
+ const dashboardPaths = [
36
+ LearnerDashboardPaths.COURSES,
37
+ LearnerDashboardPaths.CERTIFICATES,
38
+ LearnerDashboardPaths.CONTRACTS,
39
+ LearnerDashboardPaths.BATCH_ORDERS,
40
+ ];
41
+ if (context?.authentication.backend !== APIBackend.KEYCLOAK) {
42
+ dashboardPaths.push(LearnerDashboardPaths.PREFERENCES);
43
+ }
33
44
  const links = useMemo(
34
45
  () =>
35
- [
36
- LearnerDashboardPaths.COURSES,
37
- LearnerDashboardPaths.CERTIFICATES,
38
- LearnerDashboardPaths.CONTRACTS,
39
- LearnerDashboardPaths.PREFERENCES,
40
- LearnerDashboardPaths.BATCH_ORDERS,
41
- ].map((path) => ({
46
+ dashboardPaths.map((path) => ({
42
47
  to: generatePath(path),
43
48
  label: getRouteLabel(path),
44
49
  activePaths:
@@ -1,4 +1,4 @@
1
- import { getByText, screen } from '@testing-library/react';
1
+ import { getByText, queryByText, screen } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import {
@@ -17,6 +17,7 @@ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
17
17
  import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
18
18
  import { expectNoSpinner } from 'utils/test/expectSpinner';
19
19
  import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
20
+ import context from 'utils/context';
20
21
  import { DashboardTest } from './components/DashboardTest';
21
22
 
22
23
  jest.mock('utils/context', () => ({
@@ -210,4 +211,23 @@ describe('<Dashboard />', () => {
210
211
  expectUrlMatchLocationDisplayed('/dummy/route');
211
212
  expect(screen.getByRole('heading', { name: /Page not found/ }));
212
213
  });
214
+
215
+ it('should not show preferences link when using keycloak backend', async () => {
216
+ // Temporarily change the authentication backend to keycloak
217
+ const originalBackend = context.authentication.backend;
218
+ context.authentication.backend = 'keycloak';
219
+
220
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.COURSES} />, {
221
+ wrapper: BaseJoanieAppWrapper,
222
+ });
223
+ await expectNoSpinner('Loading orders and enrollments...');
224
+
225
+ const sidebar = screen.getByTestId('dashboard__sidebar');
226
+
227
+ // Verify that "My preferences" link is NOT present in the sidebar
228
+ expect(queryByText(sidebar, 'My preferences')).toBeNull();
229
+
230
+ // Restore original backend
231
+ context.authentication.backend = originalBackend;
232
+ });
213
233
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.2.2-dev27",
3
+ "version": "3.2.2-dev30",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -118,6 +118,7 @@
118
118
  "jest": "29.7.0",
119
119
  "jest-environment-jsdom": "29.7.0",
120
120
  "js-cookie": "3.0.5",
121
+ "keycloak-js": "26.2.2",
121
122
  "lodash-es": "4.17.21",
122
123
  "mdn-polyfills": "5.20.0",
123
124
  "msw": "2.7.3",
package/tsconfig.json CHANGED
@@ -19,7 +19,8 @@
19
19
  "module": "esnext",
20
20
  "moduleResolution": "node",
21
21
  "paths": {
22
- "intl-pluralrules": ["types/libs/intl-pluralrules"]
22
+ "intl-pluralrules": ["types/libs/intl-pluralrules"],
23
+ "keycloak-js": ["../node_modules/keycloak-js/lib/keycloak"]
23
24
  },
24
25
  "resolveJsonModule": true,
25
26
  "skipLibCheck": true,