Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+249
-73
@@ -38,54 +38,106 @@ afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
function seedUser(username = 'administrator', role: UserRole = 'admin', options: { disabled?: boolean; mustChangePassword?: boolean } = {}) {
|
||||
const id = randomUUID(), now = Date.now();
|
||||
database.db().prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,must_change_password,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`).run(id, username, username, passwordHash, role, Number(options.disabled ?? false), Number(options.mustChangePassword ?? false), now, now);
|
||||
function seedUser(
|
||||
username = 'administrator',
|
||||
role: UserRole = 'admin',
|
||||
options: { disabled?: boolean; mustChangePassword?: boolean } = {}
|
||||
) {
|
||||
const id = randomUUID(),
|
||||
now = Date.now();
|
||||
database
|
||||
.db()
|
||||
.prepare(
|
||||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,must_change_password,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
username,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
Number(options.disabled ?? false),
|
||||
Number(options.mustChangePassword ?? false),
|
||||
now,
|
||||
now
|
||||
);
|
||||
return auth.getUser(id)!;
|
||||
}
|
||||
function storedUser(id: string) {
|
||||
return database.db().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as { password_hash: string; role: UserRole; disabled: number; must_change_password: number };
|
||||
return database.db().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as {
|
||||
password_hash: string;
|
||||
role: UserRole;
|
||||
disabled: number;
|
||||
must_change_password: number;
|
||||
};
|
||||
}
|
||||
function gate<T>() {
|
||||
let release!: (value: T) => void;
|
||||
const promise = new Promise<T>(resolve => { release = resolve; });
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
describe('initial administrator setup', () => {
|
||||
it('reserves Maxis for system actions regardless of username case', async () => {
|
||||
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
||||
await expect(auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1'))
|
||||
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
|
||||
await expect(
|
||||
auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1')
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: 'Maxis is reserved for system actions. Choose another username.'
|
||||
});
|
||||
}
|
||||
expect(auth.setupRequired()).toBe(true);
|
||||
expect(auth.listUsers()).toEqual([]);
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
|
||||
const input = { username: 'Admin', password, token: setupToken };
|
||||
vi.stubEnv('EC_SETUP_TOKEN', undefined);
|
||||
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 });
|
||||
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({
|
||||
status: 503
|
||||
});
|
||||
vi.stubEnv('EC_SETUP_TOKEN', 'short');
|
||||
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 });
|
||||
await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({
|
||||
status: 503
|
||||
});
|
||||
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
|
||||
await expect(auth.bootstrapAdmin({ ...input, token: 'wrong' }, '127.0.0.1')).rejects.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
auth.bootstrapAdmin({ ...input, token: 'wrong' }, '127.0.0.1')
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
expect(auth.setupRequired()).toBe(true);
|
||||
expect(auth.listUsers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows exactly one competing bootstrap and atomically establishes its authenticated session', async () => {
|
||||
const results = await Promise.allSettled([
|
||||
auth.bootstrapAdmin({ username: 'FirstAdmin', displayName: ' First Admin ', password, token: setupToken, role: 'viewer' }, '192.0.2.1'),
|
||||
auth.bootstrapAdmin({ username: 'SecondAdmin', password, token: setupToken }, '192.0.2.2')
|
||||
auth.bootstrapAdmin(
|
||||
{
|
||||
username: 'FirstAdmin',
|
||||
displayName: ' First Admin ',
|
||||
password,
|
||||
token: setupToken,
|
||||
role: 'viewer'
|
||||
},
|
||||
'192.0.2.1'
|
||||
),
|
||||
auth.bootstrapAdmin(
|
||||
{ username: 'SecondAdmin', password, token: setupToken },
|
||||
'192.0.2.2'
|
||||
)
|
||||
]);
|
||||
const successes = results.filter(result => result.status === 'fulfilled');
|
||||
const failures = results.filter(result => result.status === 'rejected');
|
||||
const successes = results.filter((result) => result.status === 'fulfilled');
|
||||
const failures = results.filter((result) => result.status === 'rejected');
|
||||
expect(successes).toHaveLength(1);
|
||||
expect(failures).toHaveLength(1);
|
||||
if (failures[0].status === 'rejected') expect(failures[0].reason).toMatchObject({ status: 409 });
|
||||
if (failures[0].status === 'rejected')
|
||||
expect(failures[0].reason).toMatchObject({ status: 409 });
|
||||
if (successes[0].status !== 'fulfilled') throw new Error('Bootstrap did not succeed');
|
||||
const { user, session } = successes[0].value;
|
||||
expect(user).toMatchObject({ role: 'admin', disabled: false, mustChangePassword: false });
|
||||
@@ -93,8 +145,15 @@ describe('initial administrator setup', () => {
|
||||
expect(auth.readSession(session.token)?.user.id).toBe(user.id);
|
||||
expect(auth.listUsers()).toHaveLength(1);
|
||||
expect(auth.setupRequired()).toBe(false);
|
||||
await expect(auth.bootstrapAdmin({ username: 'ThirdAdmin', password, token: setupToken }, '192.0.2.3')).rejects.toMatchObject({ status: 409 });
|
||||
const persisted = JSON.stringify(database.db().prepare('SELECT * FROM auth_users').all()) + JSON.stringify(auth.listAuthAudit());
|
||||
await expect(
|
||||
auth.bootstrapAdmin(
|
||||
{ username: 'ThirdAdmin', password, token: setupToken },
|
||||
'192.0.2.3'
|
||||
)
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
const persisted =
|
||||
JSON.stringify(database.db().prepare('SELECT * FROM auth_users').all()) +
|
||||
JSON.stringify(auth.listAuthAudit());
|
||||
expect(persisted).not.toContain(password);
|
||||
expect(persisted).not.toContain(setupToken);
|
||||
expect(JSON.stringify(user)).not.toContain('password_hash');
|
||||
@@ -115,8 +174,17 @@ describe('password storage and verification', () => {
|
||||
});
|
||||
|
||||
it('bounds password length by Unicode characters and rejects invalid inputs before hashing', async () => {
|
||||
const { validatePassword, hashPassword, verifyPassword } = await import('../../src/lib/server/auth/passwords.js');
|
||||
for (const value of [undefined, null, 123, '', 'a'.repeat(7), '🦊'.repeat(7), 'a'.repeat(129)]) {
|
||||
const { validatePassword, hashPassword, verifyPassword } =
|
||||
await import('../../src/lib/server/auth/passwords.js');
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
123,
|
||||
'',
|
||||
'a'.repeat(7),
|
||||
'🦊'.repeat(7),
|
||||
'a'.repeat(129)
|
||||
]) {
|
||||
await expect(hashPassword(value)).rejects.toThrow('8 and 128');
|
||||
}
|
||||
expect(validatePassword('a'.repeat(8))).toBe('a'.repeat(8));
|
||||
@@ -127,19 +195,30 @@ describe('password storage and verification', () => {
|
||||
|
||||
it('bounds concurrent scrypt work and releases capacity when hashes finish', async () => {
|
||||
const { hashPassword } = await import('../../src/lib/server/auth/passwords.js');
|
||||
const first = hashPassword(password), second = hashPassword(replacement);
|
||||
const first = hashPassword(password),
|
||||
second = hashPassword(replacement);
|
||||
await expect(hashPassword('third sufficiently long passphrase')).rejects.toThrow('busy');
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
await expect(hashPassword('subsequent sufficiently long passphrase')).resolves.toMatch(/^scrypt\$/);
|
||||
await expect(hashPassword('subsequent sufficiently long passphrase')).resolves.toMatch(
|
||||
/^scrypt\$/
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the same login error for unknown, disabled and incorrect-password accounts', async () => {
|
||||
seedUser('enabled');
|
||||
seedUser('disabled', 'viewer', { disabled: true });
|
||||
for (const [username, supplied] of [['missing', password], ['enabled', replacement], ['disabled', password]]) {
|
||||
await expect(auth.authenticatePassword({ username, password: supplied }, '192.0.2.5')).rejects.toMatchObject({ status: 401, message: 'Invalid username or password.' });
|
||||
for (const [username, supplied] of [
|
||||
['missing', password],
|
||||
['enabled', replacement],
|
||||
['disabled', password]
|
||||
]) {
|
||||
await expect(
|
||||
auth.authenticatePassword({ username, password: supplied }, '192.0.2.5')
|
||||
).rejects.toMatchObject({ status: 401, message: 'Invalid username or password.' });
|
||||
}
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
||||
expect(auth.listAuthAudit()).toHaveLength(3);
|
||||
});
|
||||
@@ -150,60 +229,95 @@ describe('administrator user management', () => {
|
||||
const admin = seedUser();
|
||||
for (const role of ['admin', 'operator', 'viewer'] as const) {
|
||||
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
||||
await expect(auth.createUser(admin.id, { username, role, password }))
|
||||
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
|
||||
await expect(
|
||||
auth.createUser(admin.id, { username, role, password })
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: 'Maxis is reserved for system actions. Choose another username.'
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(auth.listUsers()).toHaveLength(1);
|
||||
expect(auth.listAuthAudit()).toEqual([]);
|
||||
await expect(auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password }))
|
||||
.resolves.toMatchObject({ username: 'maxis-operator' });
|
||||
await expect(
|
||||
auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password })
|
||||
).resolves.toMatchObject({ username: 'maxis-operator' });
|
||||
});
|
||||
|
||||
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
|
||||
const admin = seedUser();
|
||||
for (const role of ['admin', 'operator', 'viewer'] as const) {
|
||||
const user = await auth.createUser(admin.id, { username: role + 'User', displayName: ' ' + role + ' user ', role, password });
|
||||
expect(user).toMatchObject({ username: (role + 'user'), displayName: role + ' user', role, mustChangePassword: true, disabled: false });
|
||||
const user = await auth.createUser(admin.id, {
|
||||
username: role + 'User',
|
||||
displayName: ' ' + role + ' user ',
|
||||
role,
|
||||
password
|
||||
});
|
||||
expect(user).toMatchObject({
|
||||
username: role + 'user',
|
||||
displayName: role + ' user',
|
||||
role,
|
||||
mustChangePassword: true,
|
||||
disabled: false
|
||||
});
|
||||
expect(storedUser(user.id).password_hash).toMatch(/^scrypt\$/);
|
||||
expect(JSON.stringify(user)).not.toContain('password_hash');
|
||||
}
|
||||
await expect(auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(
|
||||
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(auth.listUsers()).toHaveLength(4);
|
||||
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
||||
});
|
||||
|
||||
it('rejects administrator mutations by operators, viewers, disabled administrators and forced-change administrators', async () => {
|
||||
const target = seedUser('target', 'viewer');
|
||||
const actors = [seedUser('operator', 'operator'), seedUser('viewer', 'viewer'), seedUser('disabledadmin', 'admin', { disabled: true }), seedUser('temporaryadmin', 'admin', { mustChangePassword: true })];
|
||||
const actors = [
|
||||
seedUser('operator', 'operator'),
|
||||
seedUser('viewer', 'viewer'),
|
||||
seedUser('disabledadmin', 'admin', { disabled: true }),
|
||||
seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
|
||||
];
|
||||
const session = auth.createSession(target.id);
|
||||
for (const actor of actors) {
|
||||
await expect(auth.createUser(actor.id, { username: 'intruder', role: 'admin', password })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(auth.updateUser(actor.id, target.id, { role: 'admin' })).rejects.toMatchObject({ status: 403 });
|
||||
await expect(
|
||||
auth.createUser(actor.id, { username: 'intruder', role: 'admin', password })
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
await expect(
|
||||
auth.updateUser(actor.id, target.id, { role: 'admin' })
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(() => auth.revokeUserSessions(actor.id, target.id)).toThrow('Administrator');
|
||||
}
|
||||
expect(auth.getUser(target.id)?.role).toBe('viewer');
|
||||
expect(auth.readSession(session.token)).not.toBeNull();
|
||||
expect(auth.listUsers().some(user => user.username === 'intruder')).toBe(false);
|
||||
expect(auth.listUsers().some((user) => user.username === 'intruder')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves at least one enabled administrator and permits demotion only when another remains', async () => {
|
||||
const first = seedUser();
|
||||
const session = auth.createSession(first.id);
|
||||
await expect(auth.updateUser(first.id, first.id, { role: 'operator' })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(
|
||||
auth.updateUser(first.id, first.id, { role: 'operator' })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject(
|
||||
{ status: 409 }
|
||||
);
|
||||
expect(auth.readSession(session.token)?.user.role).toBe('admin');
|
||||
const second = seedUser('secondadmin');
|
||||
await auth.updateUser(first.id, first.id, { role: 'viewer' });
|
||||
expect(auth.readSession(session.token)).toBeNull();
|
||||
await expect(auth.updateUser(second.id, second.id, { disabled: true })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(
|
||||
auth.updateUser(second.id, second.id, { disabled: true })
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(auth.getUser(second.id)?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('revokes every target session on role, enabled-state and password changes while keeping other users signed in', async () => {
|
||||
const admin = seedUser(), target = seedUser('target', 'viewer');
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const administratorSession = auth.createSession(admin.id);
|
||||
const first = auth.createSession(target.id), second = auth.createSession(target.id);
|
||||
const first = auth.createSession(target.id),
|
||||
second = auth.createSession(target.id);
|
||||
await auth.updateUser(admin.id, target.id, { role: 'operator' });
|
||||
expect(auth.readSession(first.token)).toBeNull();
|
||||
expect(auth.readSession(second.token)).toBeNull();
|
||||
@@ -219,12 +333,17 @@ describe('administrator user management', () => {
|
||||
expect(auth.readSession(fourth.token)).toBeNull();
|
||||
expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id);
|
||||
expect(storedUser(target.id).password_hash).not.toBe(passwordHash);
|
||||
await expect(auth.updateUser(admin.id, admin.id, { password: replacement })).rejects.toThrow('current password');
|
||||
await expect(
|
||||
auth.updateUser(admin.id, admin.id, { password: replacement })
|
||||
).rejects.toThrow('current password');
|
||||
});
|
||||
|
||||
it('keeps sessions for display-only edits and supports explicit per-user and individual revocation', async () => {
|
||||
const admin = seedUser(), target = seedUser('target', 'viewer');
|
||||
const one = auth.createSession(target.id), two = auth.createSession(target.id), adminSession = auth.createSession(admin.id);
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const one = auth.createSession(target.id),
|
||||
two = auth.createSession(target.id),
|
||||
adminSession = auth.createSession(admin.id);
|
||||
await auth.updateUser(admin.id, target.id, { displayName: 'New display name' });
|
||||
expect(auth.readSession(one.token)?.user.displayName).toBe('New display name');
|
||||
auth.revokeSession(one.token);
|
||||
@@ -239,34 +358,43 @@ describe('administrator user management', () => {
|
||||
describe('session secrecy and expiration', () => {
|
||||
it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => {
|
||||
const user = seedUser();
|
||||
const first = auth.createSession(user.id), second = auth.createSession(user.id);
|
||||
const first = auth.createSession(user.id),
|
||||
second = auth.createSession(user.id);
|
||||
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(first.token).not.toBe(second.token);
|
||||
const sessions = JSON.stringify(database.db().prepare('SELECT * FROM auth_sessions').all());
|
||||
expect(sessions).not.toContain(first.token);
|
||||
expect(sessions).not.toContain(second.token);
|
||||
expect(auth.readSession(first.token)?.user).not.toHaveProperty('passwordHash');
|
||||
for (const token of [undefined, '', 'x', '!', 'x'.repeat(44)]) expect(auth.readSession(token)).toBeNull();
|
||||
for (const token of [undefined, '', 'x', '!', 'x'.repeat(44)])
|
||||
expect(auth.readSession(token)).toBeNull();
|
||||
expect(auth.readSession('x'.repeat(43))).toBeNull();
|
||||
});
|
||||
|
||||
it('throttles activity touches and expires sessions at the idle boundary', () => {
|
||||
let now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const user = seedUser(), session = auth.createSession(user.id);
|
||||
const user = seedUser(),
|
||||
session = auth.createSession(user.id);
|
||||
now += 59_000;
|
||||
expect(auth.readSession(session.token)).not.toBeNull();
|
||||
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now - 59_000 });
|
||||
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({
|
||||
last_seen_at: now - 59_000
|
||||
});
|
||||
now += 2_000;
|
||||
expect(auth.readSession(session.token)).not.toBeNull();
|
||||
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now });
|
||||
expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({
|
||||
last_seen_at: now
|
||||
});
|
||||
const lastSeen = now;
|
||||
now = lastSeen + auth.SESSION_IDLE_MS - 1;
|
||||
expect(auth.readSession(session.token, false)).not.toBeNull();
|
||||
now++;
|
||||
expect(auth.readSession(session.token)).toBeNull();
|
||||
auth.createSession(user.id);
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 1 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces absolute expiry even while the user remains recently active', () => {
|
||||
@@ -274,7 +402,10 @@ describe('session secrecy and expiration', () => {
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const session = auth.createSession(seedUser().id);
|
||||
now = session.expiresAt - 1;
|
||||
database.db().prepare('UPDATE auth_sessions SET last_seen_at=?').run(now - 1000);
|
||||
database
|
||||
.db()
|
||||
.prepare('UPDATE auth_sessions SET last_seen_at=?')
|
||||
.run(now - 1000);
|
||||
expect(auth.readSession(session.token)).not.toBeNull();
|
||||
now++;
|
||||
expect(auth.readSession(session.token)).toBeNull();
|
||||
@@ -284,45 +415,71 @@ describe('session secrecy and expiration', () => {
|
||||
describe('forced password changes and asynchronous authorization races', () => {
|
||||
it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => {
|
||||
const user = seedUser('newuser', 'viewer', { mustChangePassword: true });
|
||||
const login = await auth.authenticatePassword({ username: 'NEWUSER', password }, '192.0.2.10');
|
||||
const login = await auth.authenticatePassword(
|
||||
{ username: 'NEWUSER', password },
|
||||
'192.0.2.10'
|
||||
);
|
||||
expect(login.user.mustChangePassword).toBe(true);
|
||||
expect(auth.readSession(login.session.token)?.user.mustChangePassword).toBe(true);
|
||||
await expect(auth.changePassword(user.id, replacement, 'a different long passphrase')).rejects.toThrow('Current password');
|
||||
await expect(auth.changePassword(user.id, password, password)).rejects.toThrow('different password');
|
||||
await expect(
|
||||
auth.changePassword(user.id, replacement, 'a different long passphrase')
|
||||
).rejects.toThrow('Current password');
|
||||
await expect(auth.changePassword(user.id, password, password)).rejects.toThrow(
|
||||
'different password'
|
||||
);
|
||||
expect(auth.readSession(login.session.token)).not.toBeNull();
|
||||
const changed = await auth.changePassword(user.id, password, replacement);
|
||||
expect(changed.user).toMatchObject({ id: user.id, role: 'viewer', mustChangePassword: false });
|
||||
expect(changed.user).toMatchObject({
|
||||
id: user.id,
|
||||
role: 'viewer',
|
||||
mustChangePassword: false
|
||||
});
|
||||
expect(auth.readSession(login.session.token)).toBeNull();
|
||||
expect(auth.readSession(changed.session.token)?.user.mustChangePassword).toBe(false);
|
||||
await expect(auth.authenticatePassword({ username: user.username, password }, '192.0.2.11')).rejects.toMatchObject({ status: 401 });
|
||||
await expect(
|
||||
auth.authenticatePassword({ username: user.username, password }, '192.0.2.11')
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('does not mint a session when the verified password is reset during login', async () => {
|
||||
const admin = seedUser(), target = seedUser('target', 'viewer');
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const verifying = gate<boolean>();
|
||||
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
||||
vi.spyOn(passwords, 'hashPassword').mockResolvedValueOnce(replacementHash);
|
||||
const pending = auth.authenticatePassword({ username: target.username, password }, '192.0.2.20');
|
||||
const pending = auth.authenticatePassword(
|
||||
{ username: target.username, password },
|
||||
'192.0.2.20'
|
||||
);
|
||||
await auth.updateUser(admin.id, target.id, { password: replacement });
|
||||
verifying.release(true);
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('rechecks a disabled account and current role after asynchronous login verification', async () => {
|
||||
const admin = seedUser(), target = seedUser('target', 'operator');
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'operator');
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const verifying = gate<boolean>();
|
||||
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
||||
const pending = auth.authenticatePassword({ username: target.username, password }, '192.0.2.21');
|
||||
const pending = auth.authenticatePassword(
|
||||
{ username: target.username, password },
|
||||
'192.0.2.21'
|
||||
);
|
||||
await auth.updateUser(admin.id, target.id, { disabled: true });
|
||||
verifying.release(true);
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
await auth.updateUser(admin.id, target.id, { disabled: false });
|
||||
const second = gate<boolean>();
|
||||
vi.mocked(passwords.verifyPassword).mockImplementationOnce(() => second.promise);
|
||||
const nextLogin = auth.authenticatePassword({ username: target.username, password }, '192.0.2.22');
|
||||
const nextLogin = auth.authenticatePassword(
|
||||
{ username: target.username, password },
|
||||
'192.0.2.22'
|
||||
);
|
||||
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
|
||||
second.release(true);
|
||||
const loggedIn = await nextLogin;
|
||||
@@ -331,11 +488,16 @@ describe('forced password changes and asynchronous authorization races', () => {
|
||||
});
|
||||
|
||||
it('rechecks the administrator before committing a user creation that was hashing during demotion', async () => {
|
||||
const actor = seedUser(), other = seedUser('otheradmin');
|
||||
const actor = seedUser(),
|
||||
other = seedUser('otheradmin');
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const hashing = gate<string>();
|
||||
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
|
||||
const pending = auth.createUser(actor.id, { username: 'intruder', password, role: 'admin' });
|
||||
const pending = auth.createUser(actor.id, {
|
||||
username: 'intruder',
|
||||
password,
|
||||
role: 'admin'
|
||||
});
|
||||
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
|
||||
hashing.release(passwordHash);
|
||||
await expect(pending).rejects.toMatchObject({ status: 403 });
|
||||
@@ -343,7 +505,9 @@ describe('forced password changes and asynchronous authorization races', () => {
|
||||
});
|
||||
|
||||
it('does not commit a password reset if its administrator lost access while hashing', async () => {
|
||||
const actor = seedUser(), other = seedUser('otheradmin'), target = seedUser('target', 'viewer');
|
||||
const actor = seedUser(),
|
||||
other = seedUser('otheradmin'),
|
||||
target = seedUser('target', 'viewer');
|
||||
const session = auth.createSession(target.id);
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const hashing = gate<string>();
|
||||
@@ -357,7 +521,8 @@ describe('forced password changes and asynchronous authorization races', () => {
|
||||
});
|
||||
|
||||
it('rejects a self password change whose original password was concurrently reset', async () => {
|
||||
const admin = seedUser(), target = seedUser('target', 'viewer');
|
||||
const admin = seedUser(),
|
||||
target = seedUser('target', 'viewer');
|
||||
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
||||
const verifying = gate<boolean>();
|
||||
vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise);
|
||||
@@ -366,8 +531,13 @@ describe('forced password changes and asynchronous authorization races', () => {
|
||||
await auth.updateUser(admin.id, target.id, { password: replacement });
|
||||
verifying.release(true);
|
||||
await expect(pending).rejects.toMatchObject({ status: 409 });
|
||||
expect(storedUser(target.id)).toMatchObject({ password_hash: replacementHash, must_change_password: 1 });
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
|
||||
expect(storedUser(target.id)).toMatchObject({
|
||||
password_hash: replacementHash,
|
||||
must_change_password: 1
|
||||
});
|
||||
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({
|
||||
count: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,10 +545,13 @@ describe('persistent authentication throttling', () => {
|
||||
it('retains case-insensitive per-account limits across controller database reopen and resets after the window', () => {
|
||||
let now = 2_000_000_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
|
||||
for (let attempt = 0; attempt < 10; attempt++)
|
||||
auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin');
|
||||
database.closeDb();
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).toThrow('Too many attempts');
|
||||
const persisted = JSON.stringify(database.db().prepare('SELECT * FROM auth_rate_limits').all());
|
||||
const persisted = JSON.stringify(
|
||||
database.db().prepare('SELECT * FROM auth_rate_limits').all()
|
||||
);
|
||||
expect(persisted).not.toContain('192.0.2.30');
|
||||
expect(persisted).not.toContain('admin');
|
||||
now += 15 * 60_000;
|
||||
@@ -386,8 +559,11 @@ describe('persistent authentication throttling', () => {
|
||||
});
|
||||
|
||||
it('limits attempts from one address across many accounts and separates setup from login windows', () => {
|
||||
for (let account = 0; account < 30; account++) auth.consumeAuthAttempt('192.0.2.40', 'account-' + account);
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'new-account')).toThrow('Too many attempts');
|
||||
for (let account = 0; account < 30; account++)
|
||||
auth.consumeAuthAttempt('192.0.2.40', 'account-' + account);
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'new-account')).toThrow(
|
||||
'Too many attempts'
|
||||
);
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.41', 'new-account')).not.toThrow();
|
||||
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'bootstrap', 'setup')).not.toThrow();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user