Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
370 lines
23 KiB
TypeScript
370 lines
23 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// These tests intentionally use production-strength scrypt while the complete
|
|
// suite may also be saturating the CPU. Do not weaken password parameters.
|
|
vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 });
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
import type { UserRole } from '../../src/lib/auth.js';
|
|
|
|
const password = 'correct horse battery staple';
|
|
const replacement = 'another long secret passphrase';
|
|
const setupToken = 'ec_setup_test_0123456789abcdef0123456789abcdef';
|
|
let passwordHash: string;
|
|
let replacementHash: string;
|
|
let fixture: Fixture;
|
|
let auth: typeof import('../../src/lib/server/auth/index.js');
|
|
let database: typeof import('../../src/lib/server/db/index.js');
|
|
|
|
// Real scrypt hashes are shared only as fixture data. Security transitions use
|
|
// the production API; deterministic gates below exercise its async race checks.
|
|
beforeAll(async () => {
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
passwordHash = await passwords.hashPassword(password);
|
|
replacementHash = await passwords.hashPassword(replacement);
|
|
}, 15_000);
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.stubEnv('EC_SETUP_TOKEN', setupToken);
|
|
fixture = await newFixture([]);
|
|
auth = await import('../../src/lib/server/auth/index.js');
|
|
database = await import('../../src/lib/server/db/index.js');
|
|
auth.setupRequired();
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
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);
|
|
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 };
|
|
}
|
|
function gate<T>() {
|
|
let release!: (value: T) => void;
|
|
const promise = new Promise<T>(resolve => { release = resolve; });
|
|
return { promise, release };
|
|
}
|
|
|
|
describe('initial administrator setup', () => {
|
|
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 });
|
|
vi.stubEnv('EC_SETUP_TOKEN', 'short');
|
|
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 });
|
|
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')
|
|
]);
|
|
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 (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 });
|
|
expect(user.username).toBe(user.username.toLowerCase());
|
|
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());
|
|
expect(persisted).not.toContain(password);
|
|
expect(persisted).not.toContain(setupToken);
|
|
expect(JSON.stringify(user)).not.toContain('password_hash');
|
|
});
|
|
});
|
|
|
|
describe('password storage and verification', () => {
|
|
it('uses independently salted scrypt hashes and rejects wrong passwords or malformed stored hashes', async () => {
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const anotherHash = await passwords.hashPassword(password);
|
|
expect(anotherHash).toMatch(/^scrypt\$131072\$8\$1\$[a-f0-9]{64}\$[a-f0-9]{128}$/);
|
|
expect(anotherHash).not.toBe(passwordHash);
|
|
expect(anotherHash).not.toContain(password);
|
|
expect(await passwords.verifyPassword(password, anotherHash)).toBe(true);
|
|
expect(await passwords.verifyPassword(replacement, anotherHash)).toBe(false);
|
|
expect(await passwords.verifyPassword(password, 'scrypt$1$8$1$weak$bad')).toBe(false);
|
|
expect(await passwords.verifyPassword(undefined, anotherHash)).toBe(false);
|
|
});
|
|
|
|
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, '', 'short password', 'a'.repeat(129)]) {
|
|
await expect(hashPassword(value)).rejects.toThrow('15 and 128');
|
|
}
|
|
expect(validatePassword('🦊'.repeat(15))).toBe('🦊'.repeat(15));
|
|
expect(validatePassword('a'.repeat(128))).toHaveLength(128);
|
|
expect(await verifyPassword('a'.repeat(129), passwordHash)).toBe(false);
|
|
});
|
|
|
|
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);
|
|
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\$/);
|
|
});
|
|
|
|
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.' });
|
|
}
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('administrator user management', () => {
|
|
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 });
|
|
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 });
|
|
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 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 });
|
|
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);
|
|
});
|
|
|
|
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 });
|
|
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 });
|
|
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 administratorSession = auth.createSession(admin.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();
|
|
const third = auth.createSession(target.id);
|
|
await auth.updateUser(admin.id, target.id, { disabled: true });
|
|
expect(auth.readSession(third.token)).toBeNull();
|
|
expect(() => auth.createSession(target.id)).toThrow('unavailable');
|
|
await auth.updateUser(admin.id, target.id, { disabled: false });
|
|
expect(auth.readSession(third.token)).toBeNull();
|
|
const fourth = auth.createSession(target.id);
|
|
const reset = await auth.updateUser(admin.id, target.id, { password: replacement });
|
|
expect(reset.mustChangePassword).toBe(true);
|
|
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');
|
|
});
|
|
|
|
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);
|
|
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);
|
|
expect(auth.readSession(one.token)).toBeNull();
|
|
expect(auth.readSession(two.token)).not.toBeNull();
|
|
auth.revokeUserSessions(admin.id, target.id);
|
|
expect(auth.readSession(two.token)).toBeNull();
|
|
expect(auth.readSession(adminSession.token)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
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);
|
|
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();
|
|
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);
|
|
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 });
|
|
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 });
|
|
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 });
|
|
});
|
|
|
|
it('enforces absolute expiry even while the user remains recently active', () => {
|
|
let now = 2_000_000_000_000;
|
|
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);
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
now++;
|
|
expect(auth.readSession(session.token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
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');
|
|
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');
|
|
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(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 });
|
|
});
|
|
|
|
it('does not mint a session when the verified password is reset during login', async () => {
|
|
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');
|
|
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 });
|
|
});
|
|
|
|
it('rechecks a disabled account and current role after asynchronous login verification', async () => {
|
|
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');
|
|
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');
|
|
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
|
|
second.release(true);
|
|
const loggedIn = await nextLogin;
|
|
expect(loggedIn.user.role).toBe('viewer');
|
|
expect(auth.readSession(loggedIn.session.token)?.user.role).toBe('viewer');
|
|
});
|
|
|
|
it('rechecks the administrator before committing a user creation that was hashing during demotion', async () => {
|
|
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' });
|
|
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
|
|
hashing.release(passwordHash);
|
|
await expect(pending).rejects.toMatchObject({ status: 403 });
|
|
expect(auth.listUsers()).toHaveLength(2);
|
|
});
|
|
|
|
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 session = auth.createSession(target.id);
|
|
const passwords = await import('../../src/lib/server/auth/passwords.js');
|
|
const hashing = gate<string>();
|
|
vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise);
|
|
const pending = auth.updateUser(actor.id, target.id, { password: replacement });
|
|
await auth.updateUser(other.id, actor.id, { role: 'viewer' });
|
|
hashing.release(replacementHash);
|
|
await expect(pending).rejects.toMatchObject({ status: 403 });
|
|
expect(storedUser(target.id).password_hash).toBe(passwordHash);
|
|
expect(auth.readSession(session.token)).not.toBeNull();
|
|
});
|
|
|
|
it('rejects a self password change whose original password was concurrently reset', async () => {
|
|
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').mockResolvedValue(replacementHash);
|
|
const pending = auth.changePassword(target.id, password, 'self selected new passphrase');
|
|
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 });
|
|
});
|
|
});
|
|
|
|
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');
|
|
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());
|
|
expect(persisted).not.toContain('192.0.2.30');
|
|
expect(persisted).not.toContain('admin');
|
|
now += 15 * 60_000;
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow();
|
|
});
|
|
|
|
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');
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.41', 'new-account')).not.toThrow();
|
|
expect(() => auth.consumeAuthAttempt('192.0.2.40', 'bootstrap', 'setup')).not.toThrow();
|
|
});
|
|
});
|