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
116 changes: 116 additions & 0 deletions src/api/__tests__/live-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @jest-environment node
*/
import {
changeMyPasswordAuthMePasswordPost,
loginAuthLoginPost,
meAuthMeGet,
registerAuthRegisterPost,
signOutEverywhereRouteAuthMeSignOutEverywherePost,
updateMeAuthMePatch,
} from '@/api/generated/endpoints/auth/auth';
import {
getNotificationSettingsNotificationsSettingsGet,
updateNotificationSettingsNotificationsSettingsPatch,
} from '@/api/generated/endpoints/notifications/notifications';
import { setInstanceUrl } from '@/api/instance';
import { getAccessToken, persistToken } from '@/auth/session';

/**
* Account settings against a real instance.
*
* Uses a throwaway account rather than the demo one, because these change a
* password and invalidate sessions -- doing that to the account every other
* suite signs in as would be antisocial.
*/
const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
const describeLive = LIVE_URL ? describe : describe.skip;

const RUN = Date.now().toString(36).slice(-5);
const EMAIL = `mob-settings-${RUN}@example.com`;
const PASSWORD = 'password123';

describeLive('account settings against a live instance', () => {
beforeAll(async () => {
await setInstanceUrl(LIVE_URL!);
const token = await registerAuthRegisterPost({
email: EMAIL,
password: PASSWORD,
full_name: 'Settings Tester',
});
await persistToken(token.access_token);
});

it('edits the profile fields the form offers', async () => {
const updated = await updateMeAuthMePatch({
full_name: 'Renamed Tester',
username: `mob${RUN}`,
avatar_color: '#14b8a6',
});

expect(updated.full_name).toBe('Renamed Tester');
expect(updated.username).toBe(`mob${RUN}`);
expect(updated.avatar_color).toBe('#14b8a6');
});

it('refuses an email change without the current password', async () => {
// Which is exactly why the field only appears once the address is edited.
await expect(
updateMeAuthMePatch({ email: `changed-${RUN}@example.com` }),
).rejects.toMatchObject({ response: { status: 400 } });

const withPassword = await updateMeAuthMePatch({
email: `changed-${RUN}@example.com`,
current_password: PASSWORD,
});
expect(withPassword.email).toBe(`changed-${RUN}@example.com`);
});

it('reports whether the instance can actually send email', async () => {
const settings = await getNotificationSettingsNotificationsSettingsGet();
expect(typeof settings.email_notifications).toBe('boolean');
// The toggle is disabled and explained when this is false, rather than
// silently doing nothing.
expect(typeof settings.email_delivery_configured).toBe('boolean');

const toggled = await updateNotificationSettingsNotificationsSettingsPatch({
email_notifications: !settings.email_notifications,
});
expect(toggled.email_notifications).toBe(!settings.email_notifications);
});

it('hands back a working token when the password changes', async () => {
const before = getAccessToken();

const reissued = await changeMyPasswordAuthMePasswordPost({
current_password: PASSWORD,
new_password: 'password456',
});
expect(reissued.access_token).not.toBe(before);

// The old token is dead -- which is why the app adopts the new one rather
// than leaving the user signed out of the account they just secured.
await persistToken(before);
await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } });

await persistToken(reissued.access_token);
expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`);
});

it('signs other devices out while keeping this one', async () => {
// A second session, as if from another device.
const other = await loginAuthLoginPost({
username: `changed-${RUN}@example.com`,
password: 'password456',
});

const reissued = await signOutEverywhereRouteAuthMeSignOutEverywherePost();
await persistToken(reissued.access_token);
expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`);

await persistToken(other.access_token);
await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } });

await persistToken(reissued.access_token);
});
});
24 changes: 24 additions & 0 deletions src/settings/__tests__/username.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { isValidUsername } from '@/settings/username';

describe('isValidUsername', () => {
it('accepts what the backend accepts', () => {
expect(isValidUsername('ada')).toBe(true);
expect(isValidUsername('ada.lovelace')).toBe(true);
expect(isValidUsername('ada_2')).toBe(true);
expect(isValidUsername('a1')).toBe(true);
});

it('refuses what it would reject', () => {
// Must start with a letter or digit.
expect(isValidUsername('.ada')).toBe(false);
expect(isValidUsername('-ada')).toBe(false);
// Lowercase only, and no spaces or @.
expect(isValidUsername('Ada')).toBe(false);
expect(isValidUsername('ada lovelace')).toBe(false);
expect(isValidUsername('ada@example.com')).toBe(false);
// Two to thirty-nine characters.
expect(isValidUsername('a')).toBe(false);
expect(isValidUsername('a'.repeat(40))).toBe(false);
expect(isValidUsername('a'.repeat(39))).toBe(true);
});
});
12 changes: 12 additions & 0 deletions src/settings/username.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* The username rule, mirrored from `backend/lib_identity/usernames.py`.
*
* Checked before submitting so the form answers rather than the server -- a
* round trip to be told a dash is fine but a space is not reads as a fault in
* the app.
*/
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,38}$/;

export function isValidUsername(username: string): boolean {
return USERNAME_PATTERN.test(username);
}
Loading
Loading