Files
feuerwerk/tests/core/auth.test.ts
T
adminandClaude Opus 5 c830c7a3aa
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m18s
Changing the password: the Logout button at the far right of the toolbar became an Account menu that names the signed-in account in an entry of its own that cannot be picked, with Change password... above Logout, and the new dialog (src/editor/auth/PasswordDialog.ts) asks for the current password, the new one and its repetition, all three as bullets, and stays standing when the server refuses so that it can say why, unlike the other form dialogs; the rules for empty, too short, new equal to old and a differing repetition sit as the pure function passwordProblem() next to loginProblem(), the API gained POST ?r=auth/password which answers 204, insists on the current password although a session is there, enforces a minimum length of 8 in UserStore::MIN_PASSWORD_LENGTH and again in the client so the mask can answer without asking the server, and the new SessionStore::destroyAllFor($user, $keep) ends every other session of the account while the tab that changed the password keeps its own; the placeholders admin/admin and user/user stay as they are, which the length check does not touch because it only runs when a password is set through the API; e2e 45 grew by 8 checks for the menu, the dialog, both refusals and signing in with the old and the new password, and it sets the e2e account back at the end - a run dying in between leaves it on <password>-tmp, which a make-user.php call repairs (#182)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:46:40 +02:00

141 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WrongCredentialsError } from '../../src/core/store/AuthStore';
import { HttpAuthStore } from '../../src/core/store/HttpAuthStore';
import {
loginError,
loginProblem,
MIN_PASSWORD_LENGTH,
passwordError,
passwordProblem,
} from '../../src/editor/auth/loginRules';
/** What the firewall in front of the live site answers instead of any non-2xx (measured for #177). */
const FIREWALL_418 = '<html><head><title>Status Code 418</title></head><body></body></html>';
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('HttpAuthStore', () => {
it('reads who is signed in, null included', async () => {
stubFetch(JSON.stringify({ user: 'e2e' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBe('e2e');
stubFetch(JSON.stringify({ user: null }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBeNull();
});
it('sends the pair as JSON and answers with the name the server confirms', async () => {
const mock = stubFetch(JSON.stringify({ user: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toBe('user');
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Flogin');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toEqual({ username: 'user', password: 'secret' });
});
it('tells a wrong pair apart from a server that is not there', async () => {
stubFetch(JSON.stringify({ error: 'Wrong user name or password' }), { status: 401 });
await expect(new HttpAuthStore('/api').logIn('user', 'nope')).rejects.toBeInstanceOf(
WrongCredentialsError,
);
stubFetch(JSON.stringify({ error: 'boom' }), { status: 500 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/boom \(500\)/);
});
// The firewall in front of nockal.com used to answer every non-2xx with this page; since
// 2026-09-20 it hands the API's own status through. The rule stays: only a 401 from the API
// means a wrong pair, anything a box in between makes up does not.
it('does not mistake a proxy’s own page for a wrong password', async () => {
stubFetch(FIREWALL_418, { status: 418 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/418/);
});
it('signs out without a body', async () => {
const mock = stubFetch(null, { status: 204 });
await expect(new HttpAuthStore('/api').logOut()).resolves.toBeUndefined();
expect(mock.mock.calls[0]?.[1]?.method).toBe('POST');
});
it('sends both passwords to change one and takes 204 for done', async () => {
const mock = stubFetch(null, { status: 204 });
await expect(new HttpAuthStore('/api').changePassword('old', 'a-new-one')).resolves.toBeUndefined();
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Fpassword');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toEqual({
currentPassword: 'old',
newPassword: 'a-new-one',
});
});
it('tells a wrong current password apart from a refused new one', async () => {
stubFetch(JSON.stringify({ error: 'Wrong password' }), { status: 401 });
await expect(new HttpAuthStore('/api').changePassword('nope', 'a-new-one')).rejects.toBeInstanceOf(
WrongCredentialsError,
);
// 422 is the server turning the new password down, which is not a wrong pair and must not be
// shown as one: its own wording travels instead.
stubFetch(JSON.stringify({ error: 'The new password needs at least 8 characters' }), { status: 422 });
const refused = new HttpAuthStore('/api').changePassword('old', 'short');
await expect(refused).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(refused).rejects.toThrow(/at least 8 characters \(422\)/);
});
});
describe('the rules of the sign-in form', () => {
it('asks for what is missing, user name first', () => {
expect(loginProblem('', '')).toBe('Enter your user name');
expect(loginProblem(' ', 'secret')).toBe('Enter your user name');
expect(loginProblem('user', '')).toBe('Enter your password');
expect(loginProblem('user', 'secret')).toBeNull();
});
it('says a wrong pair plainly and names the cause of anything else', () => {
expect(loginError(new WrongCredentialsError(), true)).toBe('Wrong user name or password');
expect(loginError(new Error('Server unavailable (503)'), false)).toBe(
'Could not sign in: Server unavailable (503)',
);
});
});
describe('the rules of the change-password form', () => {
const long = 'a'.repeat(MIN_PASSWORD_LENGTH);
it('asks for what is missing, the current password first', () => {
expect(passwordProblem('', '', '')).toBe('Enter your current password');
expect(passwordProblem('old', '', '')).toBe('Enter a new password');
expect(passwordProblem('old', long, long)).toBeNull();
});
it('refuses a new password that is too short, before anything is sent', () => {
expect(
passwordProblem('old', 'a'.repeat(MIN_PASSWORD_LENGTH - 1), 'a'.repeat(MIN_PASSWORD_LENGTH - 1)),
).toBe(`The new password needs at least ${MIN_PASSWORD_LENGTH} characters`);
});
it('refuses the old password again and a repeat that differs', () => {
expect(passwordProblem(long, long, long)).toBe('The new password is the old one');
expect(passwordProblem('old', long, `${long}x`)).toBe('The two new passwords are different');
});
it('names the wrong current password and the cause of anything else', () => {
expect(passwordError(new WrongCredentialsError('Wrong password'), true)).toBe('Wrong current password');
expect(passwordError(new Error('Server error (500)'), false)).toBe(
'Could not change the password: Server error (500)',
);
});
});