Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
247 changes: 185 additions & 62 deletions client/modules/User/actions.js → client/modules/User/actions.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import type { CookieConsentOptions, PublicUser } from '../../../common/types';
import * as ActionTypes from '../../constants';

const user = (state = { authenticated: false }, action) => {
// User Action:
export type UserAction = {
user?: PublicUser;
cookieConsent?: CookieConsentOptions;
type: any;
};

export const user = (
state: Partial<PublicUser> & {
authenticated: boolean;
// TODO: use state of user from server as single source of truth:
// Currently using redux state below, but server also has similar info.
resetPasswordInitiate?: boolean;
resetPasswordInvalid?: boolean;
emailVerificationInitiate?: boolean;
emailVerificationTokenState?: boolean;
} = {
authenticated: false
},
action: UserAction
) => {
switch (action.type) {
case ActionTypes.AUTH_USER:
return {
Expand Down Expand Up @@ -47,5 +68,3 @@ const user = (state = { authenticated: false }, action) => {
return state;
}
};

export default user;
2 changes: 1 addition & 1 deletion client/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ide from './modules/IDE/reducers/ide';
import { preferences } from './modules/IDE/reducers/preferences';
import project from './modules/IDE/reducers/project';
import editorAccessibility from './modules/IDE/reducers/editorAccessibility';
import user from './modules/User/reducers';
import { user } from './modules/User/reducers';
import sketches from './modules/IDE/reducers/projects';
import toast from './modules/IDE/reducers/toast';
import console from './modules/IDE/reducers/console';
Expand Down
6 changes: 5 additions & 1 deletion client/testData/testReduxStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ const initialTestState: RootState = {
user: {
email: '[email protected]',
username: 'happydog',
preferences: {},
preferences: {
...initialPrefState,
indentationAmount: 2,
isTabIndent: true
},
apiKeys: [],
verified: 'sent',
id: '123456789',
Expand Down
15 changes: 11 additions & 4 deletions server/controllers/user.controller/__testUtils__.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const mockBaseUserSanitised: PublicUser = {
email: '[email protected]',
username: 'tester',
preferences: mockUserPreferences,
apiKeys: ([] as unknown) as Types.DocumentArray<ApiKeyDocument>,
apiKeys: [],
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for a PublicUser, apiKeys is SanitisedApiKey[]

verified: 'verified',
id: 'abc123',
totalSize: 42,
Expand All @@ -42,6 +42,7 @@ export const mockBaseUserSanitised: PublicUser = {
export const mockBaseUserFull: Omit<User, 'createdAt'> = {
...mockBaseUserSanitised,
name: 'test user',
apiKeys: ([] as unknown) as Types.DocumentArray<ApiKeyDocument>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for a UserDocument, apiKeys is DocumentArray<ApiKeyDocument> -- It needs access to methods like apiKeys.pull

tokens: [],
password: 'abweorij',
resetPasswordToken: '1i14ij23',
Expand All @@ -58,9 +59,15 @@ export const mockBaseUserFull: Omit<User, 'createdAt'> = {
export function createMockUser(
overrides: Partial<UserDocument> = {},
unSanitised: boolean = false
): PublicUser & Record<string, any> {
): PublicUser | UserDocument {
if (unSanitised) {
return {
...mockBaseUserFull,
...overrides
} as UserDocument;
}
return {
...(unSanitised ? mockBaseUserFull : mockBaseUserSanitised),
...mockBaseUserSanitised,
...overrides
};
} as PublicUser;
Copy link
Collaborator Author

@clairep94 clairep94 Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make the return type of each case super explicit, but we seem to still need to cast the return value of calling this function anyway to resolve type-errors for when the usecase requires UserDocument methods

}
27 changes: 17 additions & 10 deletions server/controllers/user.controller/__tests__/apiKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { Types } from 'mongoose';

import { User } from '../../../models/user';
import { createApiKey, removeApiKey } from '../apiKey';
import type { ApiKeyDocument, RemoveApiKeyRequestParams } from '../../../types';
import type {
ApiKeyDocument,
RemoveApiKeyRequestParams,
UserDocument
} from '../../../types';
import { createMockUser } from '../__testUtils__';

jest.mock('../../../models/user');
Expand All @@ -31,7 +35,7 @@ describe('user.controller > api key', () => {

describe('createApiKey', () => {
it("returns an error if user doesn't exist", async () => {
request.user = createMockUser({ id: '1234' });
request.user = createMockUser({ id: '1234' }, true);

User.findById = jest.fn().mockResolvedValue(null);

Expand All @@ -48,7 +52,7 @@ describe('user.controller > api key', () => {
});

it('returns an error if label not provided', async () => {
request.user = createMockUser({ id: '1234' });
request.user = createMockUser({ id: '1234' }, true);
request.body = {};

const user = new User();
Expand Down Expand Up @@ -98,7 +102,7 @@ describe('user.controller > api key', () => {

describe('removeApiKey', () => {
it("returns an error if user doesn't exist", async () => {
request.user = createMockUser({ id: '1234' });
request.user = createMockUser({ id: '1234' }, true);

User.findById = jest.fn().mockResolvedValue(null);

Expand All @@ -115,7 +119,7 @@ describe('user.controller > api key', () => {
});

it("returns an error if specified key doesn't exist", async () => {
request.user = createMockUser({ id: '1234' });
request.user = createMockUser({ id: '1234' }, true);
request.params = { keyId: 'not-a-real-key' };
const user = new User();
user.apiKeys = ([] as unknown) as Types.DocumentArray<ApiKeyDocument>;
Expand Down Expand Up @@ -145,11 +149,14 @@ describe('user.controller > api key', () => {
apiKeys.find = Array.prototype.find;
apiKeys.pull = jest.fn();

const user = createMockUser({
id: '1234',
apiKeys,
save: jest.fn()
});
const user = createMockUser(
{
id: '1234',
apiKeys,
save: jest.fn()
},
true
) as UserDocument;

request.user = user;
request.params = { keyId: 'id1' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Request, Response } from 'express';
import { unlinkGithub, unlinkGoogle } from '../../authManagement';
import { saveUser } from '../../helpers';
import { createMockUser } from '../../__testUtils__';
import { UserDocument } from '../../../../types';

jest.mock('../../helpers', () => ({
...jest.requireActual('../../helpers'),
Expand Down Expand Up @@ -50,10 +51,13 @@ describe('user.controller > auth management > 3rd party auth', () => {
});
});
describe('and when there is a user in the request', () => {
const user = createMockUser({
github: 'testuser',
tokens: [{ kind: 'github' }, { kind: 'google' }]
});
const user = createMockUser(
{
github: 'testuser',
tokens: [{ kind: 'github' }, { kind: 'google' }]
},
true
) as UserDocument;

beforeEach(async () => {
request.user = user;
Expand Down Expand Up @@ -96,10 +100,13 @@ describe('user.controller > auth management > 3rd party auth', () => {
});
});
describe('and when there is a user in the request', () => {
const user = createMockUser({
google: 'testuser',
tokens: [{ kind: 'github' }, { kind: 'google' }]
});
const user = createMockUser(
{
google: 'testuser',
tokens: [{ kind: 'github' }, { kind: 'google' }]
},
true
) as UserDocument;

beforeEach(async () => {
request.user = user;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('user.controller > auth management > password management', () => {
let response: MockResponse;
let next: MockNext;
let mockToken: string;
let mockUser: Partial<UserDocument>;
let mockUser: UserDocument;
const fixedTime = 100000000;

beforeEach(() => {
Expand Down Expand Up @@ -68,10 +68,13 @@ describe('user.controller > auth management > password management', () => {
describe('if the user is found', () => {
beforeEach(async () => {
mockToken = 'mock-token';
mockUser = createMockUser({
email: '[email protected]',
save: jest.fn().mockResolvedValue(null)
});
mockUser = createMockUser(
{
email: '[email protected]',
save: jest.fn().mockResolvedValue(null)
},
false
) as UserDocument;

(generateToken as jest.Mock).mockResolvedValue(mockToken);
User.findByEmail = jest.fn().mockResolvedValue(mockUser);
Expand Down Expand Up @@ -143,10 +146,13 @@ describe('user.controller > auth management > password management', () => {
});
it('returns unsuccessful for all other errors', async () => {
mockToken = 'mock-token';
mockUser = createMockUser({
email: '[email protected]',
save: jest.fn().mockResolvedValue(null)
});
mockUser = createMockUser(
{
email: '[email protected]',
save: jest.fn().mockResolvedValue(null)
},
false
) as UserDocument;

(generateToken as jest.Mock).mockRejectedValue(
new Error('network error')
Expand Down Expand Up @@ -298,7 +304,7 @@ describe('user.controller > auth management > password management', () => {
resetPasswordToken: 'valid-token',
resetPasswordExpires: fixedTime + 10000, // still valid
save: jest.fn()
};
} as UserDocument;

beforeEach(async () => {
User.findOne = jest.fn().mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ describe('user.controller > auth management > updateSettings (email, username, p
response = new MockResponse();
next = jest.fn();

startingUser = createMockUser({
username: OLD_USERNAME,
email: OLD_EMAIL,
password: OLD_PASSWORD,
id: '123459',
comparePassword: jest.fn().mockResolvedValue(true)
});
startingUser = createMockUser(
{
username: OLD_USERNAME,
email: OLD_EMAIL,
password: OLD_PASSWORD,
id: '123459',
comparePassword: jest.fn().mockResolvedValue(true)
},
false
) as UserDocument;

testUser = { ...startingUser }; // copy to avoid mutation causing false-positive tests results

Expand Down
19 changes: 11 additions & 8 deletions server/controllers/user.controller/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import { UserDocument } from '../../../types';

jest.mock('../../../models/user');

const mockFullUser = createMockUser({
// sensitive fields to be removed:
name: 'bob dylan',
tokens: [],
password: 'password12314',
resetPasswordToken: 'wijroaijwoer',
banned: true
});
const mockFullUser = createMockUser(
{
// sensitive fields to be removed:
name: 'bob dylan',
tokens: [],
password: 'password12314',
resetPasswordToken: 'wijroaijwoer',
banned: true
},
true
) as UserDocument;

const {
name,
Expand Down
3 changes: 2 additions & 1 deletion server/controllers/user.controller/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export const createApiKey: RequestHandler<
await user.save();

const apiKeys = user.apiKeys.map((apiKey, index) => {
const fields = apiKey.toObject!();
const fields = apiKey.toObject();
// only include the token of the most recently made apiKey to display in the copiable field
const shouldIncludeToken = index === addedApiKeyIndex - 1;

return shouldIncludeToken ? { ...fields, token: keyToBeHashed } : fields;
Expand Down
20 changes: 17 additions & 3 deletions server/controllers/user.controller/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import crypto from 'crypto';
import type { Response } from 'express';
import { User } from '../../models/user';
import { PublicUser, UserDocument } from '../../types';
import {
ApiKeyDocument,
PublicUser,
SanitisedApiKey,
UserDocument
} from '../../types';

export function sanitiseApiKey(key: ApiKeyDocument): SanitisedApiKey {
return {
id: key.id,
label: key.label,
lastUsedAt: key.lastUsedAt,
createdAt: key.createdAt
};
}

/**
* Sanitise user objects to remove sensitive fields
* @param user
* @returns Sanitised user
*/
export function userResponse(user: PublicUser | UserDocument): PublicUser {
export function userResponse(user: UserDocument): PublicUser {
return {
email: user.email,
username: user.username,
preferences: user.preferences,
apiKeys: user.apiKeys,
apiKeys: user.apiKeys.map((el) => sanitiseApiKey(el)),
Comment on lines +11 to +30
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of the bigger changes on this PR:
After manual testing/logging what userResponse gives us on Postman, I saw that apiKeys is the SanitisedApiKey, with the last element containing token

verified: user.verified,
id: user.id,
totalSize: user.totalSize,
Expand Down
10 changes: 7 additions & 3 deletions server/types/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@ export interface IApiKey extends VirtualId, MongooseTimestamps {
label: string;
lastUsedAt?: Date;
hashedKey: string;
token?: string;
}

/** Mongoose document object for API Key */
export interface ApiKeyDocument
extends IApiKey,
Omit<Document<Types.ObjectId>, 'id'> {
toJSON(options?: any): SanitisedApiKey;
toObject(options?: any): SanitisedApiKey;
toJSON(options?: any): IApiKey;
toObject(options?: any): IApiKey;
Comment on lines +18 to +19
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously typed this as SanitisedApiKey, but it's the full ApiKey till it gets sanitised

}

/**
* Sanitised API key object which hides the `hashedKey` field
* and can be exposed to the client
*/
export interface SanitisedApiKey
extends Pick<ApiKeyDocument, 'id' | 'label' | 'lastUsedAt' | 'createdAt'> {}
extends Pick<
ApiKeyDocument,
'id' | 'label' | 'lastUsedAt' | 'createdAt' | 'token'
> {}

/** Mongoose model for API Key */
export interface ApiKeyModel extends Model<ApiKeyDocument> {}
Expand Down
Loading
Loading