mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the auth service spec through the factories
Converts the raw prepare() fixtures and reads in the auth service spec to the ORM factories: instance settings go through setAppSetting and deleteRows, MFA, OIDC and invite state through updateRows, and the password version, push device and MCP token checks read back through readUser and countRows. The push device helpers become async, so every caller now awaits them. The unused canAccessTrip and isOwner entries are dropped from the database mock, since nothing under test imports them.
This commit is contained in:
@@ -21,14 +21,6 @@ vi.mock('../../../src/db/database', async () => {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`
|
||||
)
|
||||
.get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return mock;
|
||||
});
|
||||
@@ -95,6 +87,16 @@ import {
|
||||
createTestPasswordResetTokensRepo,
|
||||
} from '../../helpers/test-uow';
|
||||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||||
import type { TestOrm } from '../../helpers/test-orm';
|
||||
import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
|
||||
import { readUser } from '../../helpers/factories/users';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity';
|
||||
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
|
||||
import { PasswordResetTokens } from '../../../src/db/entities/PasswordResetTokens.entity';
|
||||
import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
|
||||
@@ -136,23 +138,27 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
/** A Web Push device row; the password paths only care whose it is. */
|
||||
function addPushDevice(userId: number, endpoint: string): void {
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
|
||||
)
|
||||
.run(userId, endpoint);
|
||||
async function addPushDevice(userId: number, endpoint: string): Promise<void> {
|
||||
await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' });
|
||||
}
|
||||
|
||||
function pushDeviceCount(userId: number): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) c FROM push_subscriptions WHERE user_id = ?').get(userId) as { c: number }).c;
|
||||
function pushDeviceCount(userId: number): Promise<number> {
|
||||
return countRows(orm, PushSubscriptions, { user: userId });
|
||||
}
|
||||
|
||||
let orm: TestOrm;
|
||||
|
||||
/** Writes an instance setting, replacing any earlier value. */
|
||||
const appSetting = (key: string, value: string) => setAppSetting(orm, key, value);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
orm = await sharedTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
@@ -165,15 +171,13 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// OIDC users are created with a random bcrypt hash, so password_hash is set —
|
||||
// the old guard keyed off a missing hash and therefore let the reset through.
|
||||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||||
.run('sub-1129', 'https://idp.example', user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-1129', oidc_issuer: 'https://idp.example' });
|
||||
|
||||
const result = await svc.requestPasswordReset(user.email, null);
|
||||
|
||||
expect(result.reason).toBe('oidc_only');
|
||||
expect(result.tokenForDelivery).toBeNull();
|
||||
const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?')
|
||||
.get(user.id) as { n: number };
|
||||
const n = await countRows(orm, PasswordResetTokens, { user: user.id });
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
|
||||
@@ -188,8 +192,7 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await svc.requestPasswordReset(user.email, null);
|
||||
await svc.requestPasswordReset(user.email, null);
|
||||
const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ? AND consumed_at IS NULL')
|
||||
.get(user.id) as { n: number };
|
||||
const n = await countRows(orm, PasswordResetTokens, { user: user.id, consumed_at: null });
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -208,9 +211,7 @@ describe('getAppSettings', () => {
|
||||
|
||||
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
|
||||
.run();
|
||||
await appSetting('allow_registration', 'true');
|
||||
const result = await svc.getAppSettings(user.id);
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.data).toBeDefined();
|
||||
@@ -251,9 +252,11 @@ describe('isOidcOnlyMode', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAuthToggles', () => {
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run();
|
||||
await deleteRows(orm, AppSettings, {
|
||||
key: { $in: ['password_login', 'password_registration', 'oidc_login', 'oidc_registration', 'oidc_only', 'allow_registration'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', async () => {
|
||||
@@ -278,7 +281,7 @@ describe('resolveAuthToggles', () => {
|
||||
|
||||
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await appSetting('allow_registration', 'false');
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(true);
|
||||
expect(t.password_registration).toBe(false);
|
||||
@@ -288,8 +291,8 @@ describe('resolveAuthToggles', () => {
|
||||
|
||||
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
||||
await appSetting('allow_registration', 'false');
|
||||
await appSetting('password_registration', 'true');
|
||||
const t = await svc.resolveAuthToggles();
|
||||
// New key present → use new keys, allow_registration ignored
|
||||
expect(t.password_registration).toBe(true);
|
||||
@@ -298,8 +301,8 @@ describe('resolveAuthToggles', () => {
|
||||
|
||||
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
|
||||
await appSetting('password_login', 'true');
|
||||
await appSetting('password_registration', 'true');
|
||||
const t = await svc.resolveAuthToggles();
|
||||
// OIDC_ONLY forces password toggles off even when DB says true
|
||||
expect(t.password_login).toBe(false);
|
||||
@@ -308,10 +311,10 @@ describe('resolveAuthToggles', () => {
|
||||
|
||||
it('AUTH-DB-022f: individual granular keys can be set independently', async () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run();
|
||||
await appSetting('password_login', 'true');
|
||||
await appSetting('password_registration', 'false');
|
||||
await appSetting('oidc_login', 'true');
|
||||
await appSetting('oidc_registration', 'false');
|
||||
const t = await svc.resolveAuthToggles();
|
||||
expect(t.password_login).toBe(true);
|
||||
expect(t.password_registration).toBe(false);
|
||||
@@ -336,7 +339,7 @@ describe('setupMfa', () => {
|
||||
|
||||
it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
||||
const result = await svc.setupMfa(user.id, user.email);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/already enabled/i);
|
||||
@@ -427,7 +430,7 @@ describe('validateInviteToken', () => {
|
||||
// createInviteToken with used_count already at max
|
||||
const invite = createInviteToken(testDb, { max_uses: 1 });
|
||||
// manually set used_count = 1 to simulate exhaustion
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
||||
const result = await svc.validateInviteToken(invite.token);
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
@@ -446,9 +449,9 @@ describe('validateInviteToken', () => {
|
||||
describe('registerUser — OIDC-only / registration-disabled', () => {
|
||||
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', async () => {
|
||||
createUser(testDb); // ensure userCount > 0
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
await insertRow(orm, AppSettings, { key: 'oidc_only', value: 'true' });
|
||||
await insertRow(orm, AppSettings, { key: 'oidc_issuer', value: 'https://x' });
|
||||
await insertRow(orm, AppSettings, { key: 'oidc_client_id', value: 'id' });
|
||||
|
||||
const result = await svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
|
||||
expect(result.status).toBe(403);
|
||||
@@ -457,7 +460,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
|
||||
|
||||
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', async () => {
|
||||
createUser(testDb); // ensure userCount > 0
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await appSetting('allow_registration', 'false');
|
||||
|
||||
const result = await svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
|
||||
expect(result.status).toBe(403);
|
||||
@@ -471,9 +474,9 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
|
||||
describe('loginUser — OIDC-only mode', () => {
|
||||
it('AUTH-DB-035: returns 403 when oidc_only=true', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
await appSetting('oidc_only', 'true');
|
||||
await appSetting('oidc_issuer', 'https://x');
|
||||
await appSetting('oidc_client_id', 'id');
|
||||
|
||||
const result = await svc.loginUser({ email: user.email, password });
|
||||
expect(result.status).toBe(403);
|
||||
@@ -487,9 +490,9 @@ describe('loginUser — OIDC-only mode', () => {
|
||||
describe('changePassword — OIDC-only mode', () => {
|
||||
it('AUTH-DB-036: returns 403 when oidc_only=true', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
await appSetting('oidc_only', 'true');
|
||||
await appSetting('oidc_issuer', 'https://x');
|
||||
await appSetting('oidc_client_id', 'id');
|
||||
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
expect(result.status).toBe(403);
|
||||
@@ -497,24 +500,22 @@ describe('changePassword — OIDC-only mode', () => {
|
||||
});
|
||||
|
||||
describe('changePassword — session invalidation', () => {
|
||||
const pvOf = (id: number) =>
|
||||
(testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number }).password_version;
|
||||
const mcpCount = (id: number) =>
|
||||
(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c;
|
||||
const pvOf = async (id: number) => (await readUser(orm, id)).password_version;
|
||||
const mcpCount = (id: number) => countRows(orm, McpTokens, { user: id });
|
||||
|
||||
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
await tokens.createMcpToken(user.id, 'cli');
|
||||
|
||||
expect(pvOf(user.id)).toBe(0);
|
||||
expect(mcpCount(user.id)).toBe(1);
|
||||
expect(await pvOf(user.id)).toBe(0);
|
||||
expect(await mcpCount(user.id)).toBe(1);
|
||||
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(typeof result.token).toBe('string'); // fresh session for the current device
|
||||
expect(pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate
|
||||
expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked
|
||||
expect(await pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate
|
||||
expect(await mcpCount(user.id)).toBe(0); // static MCP tokens revoked
|
||||
});
|
||||
|
||||
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', async () => {
|
||||
@@ -556,16 +557,16 @@ describe('changePassword — session invalidation', () => {
|
||||
it('AUTH-DB-036e: the block is atomic — a failing mcp_tokens prune rolls the whole password change back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
await tokens.createMcpToken(user.id, 'cli');
|
||||
const hashBefore = (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash;
|
||||
const hashBefore = (await readUser(orm, user.id)).password_hash;
|
||||
|
||||
const mcpTokensRepo = await createTestMcpTokensRepo(testDb);
|
||||
const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
await expect(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).rejects.toThrow('boom');
|
||||
|
||||
expect(pvOf(user.id)).toBe(0); // password_version unchanged
|
||||
expect((testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash).toBe(hashBefore); // old hash unchanged
|
||||
expect(mcpCount(user.id)).toBe(1); // the mcp_tokens row survives — the DELETE never committed
|
||||
expect(await pvOf(user.id)).toBe(0); // password_version unchanged
|
||||
expect((await readUser(orm, user.id)).password_hash).toBe(hashBefore); // old hash unchanged
|
||||
expect(await mcpCount(user.id)).toBe(1); // the mcp_tokens row survives — the DELETE never committed
|
||||
} finally {
|
||||
deleteAllForUserSpy.mockRestore();
|
||||
}
|
||||
@@ -574,31 +575,31 @@ describe('changePassword — session invalidation', () => {
|
||||
it('AUTH-DB-036f: forgets every push device of the user, and only of that user', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1');
|
||||
addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
|
||||
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine-1');
|
||||
await addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
|
||||
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(pushDeviceCount(other.id)).toBe(1);
|
||||
expect(await pushDeviceCount(user.id)).toBe(0);
|
||||
expect(await pushDeviceCount(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-036g: a refused change keeps the push devices', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
|
||||
const result = await svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' });
|
||||
|
||||
expect(result.status).toBe(401);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
expect(await pushDeviceCount(user.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-036h: drops the push devices in the same transaction as the password change, so a failure changes nothing', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
await tokens.createMcpToken(user.id, 'cli');
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
try {
|
||||
await expect(
|
||||
@@ -608,9 +609,9 @@ describe('changePassword — session invalidation', () => {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
|
||||
expect(pvOf(user.id)).toBe(0);
|
||||
expect(mcpCount(user.id)).toBe(1);
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
expect(await pvOf(user.id)).toBe(0);
|
||||
expect(await mcpCount(user.id)).toBe(1);
|
||||
expect(await pushDeviceCount(user.id)).toBe(1);
|
||||
// The new hash rolled back as well, so the old password is still the one that counts.
|
||||
expect((await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).success)
|
||||
.toBe(true);
|
||||
@@ -624,7 +625,7 @@ describe('changePassword — session invalidation', () => {
|
||||
describe('disableMfa — require_mfa policy', () => {
|
||||
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await appSetting('require_mfa', 'true');
|
||||
|
||||
const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
||||
expect(result.status).toBe(403);
|
||||
@@ -688,16 +689,16 @@ describe('getAppConfig', () => {
|
||||
// hardcoded copy dropped pkpass, pkpasses, md and markdown, so the client
|
||||
// greyed out types the server accepts.
|
||||
createUser(testDb);
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'allowed_file_types'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'allowed_file_types' });
|
||||
const cfg = await svc.getAppConfig(null);
|
||||
expect(cfg.allowed_file_types).toBe(DEFAULT_ALLOWED_EXTENSIONS);
|
||||
});
|
||||
|
||||
it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('notification_channels', 'email,webhook')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'jpg,png')").run();
|
||||
await appSetting('require_mfa', 'true');
|
||||
await appSetting('notification_channels', 'email,webhook');
|
||||
await appSetting('allowed_file_types', 'jpg,png');
|
||||
const cfg = await svc.getAppConfig({ id: user.id } as never);
|
||||
expect(cfg.permissions).toBeDefined();
|
||||
expect(cfg.require_mfa).toBe(true);
|
||||
@@ -754,7 +755,7 @@ describe('getAppConfig', () => {
|
||||
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('webauthn_rp_id', 'localhost')").run();
|
||||
await appSetting('webauthn_rp_id', 'localhost');
|
||||
expect((await svc.getAppConfig(null)).passkey_configured).toBe(true);
|
||||
});
|
||||
|
||||
@@ -769,17 +770,17 @@ describe('getAppConfig', () => {
|
||||
|
||||
it('AUTH-DB-052g: place_shadow_enabled fails closed, on the unauthenticated payload', async () => {
|
||||
createUser(testDb);
|
||||
const set = testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('place_shadow_enabled', ?)");
|
||||
const set = { run: (value: string) => appSetting('place_shadow_enabled', value) };
|
||||
// No row is the state of every install that never touched the switch.
|
||||
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
|
||||
set.run('false');
|
||||
await set.run('false');
|
||||
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
|
||||
// Only the literal 'true' opens it, the mirror image of the fail-open
|
||||
// places_* flags beside it, which close only on the literal 'false'.
|
||||
set.run('1');
|
||||
await set.run('1');
|
||||
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(false);
|
||||
expect((await svc.getAppConfig(null)).places_enrich_enabled).toBe(true);
|
||||
set.run('true');
|
||||
await set.run('true');
|
||||
expect((await svc.getAppConfig(null)).place_shadow_enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -835,13 +836,13 @@ describe('registerUser — success paths', () => {
|
||||
|
||||
it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', async () => {
|
||||
createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await appSetting('allow_registration', 'false');
|
||||
const invite = createInviteToken(testDb, { max_uses: 2 });
|
||||
const result = await svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token });
|
||||
expect(result.error).toBeUndefined();
|
||||
const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
|
||||
expect(used_count).toBe(1);
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'allow_registration'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'allow_registration' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -869,7 +870,7 @@ describe('loginUser — credential branches', () => {
|
||||
expect(typeof result.token).toBe('string');
|
||||
expect(result.user).not.toHaveProperty('password_hash');
|
||||
expect(result.auditAction).toBe('user.login');
|
||||
const row = testDb.prepare('SELECT login_count, last_login FROM users WHERE id = ?').get(user.id) as { login_count: number; last_login: string };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.login_count).toBe(1);
|
||||
expect(row.last_login).not.toBeNull();
|
||||
});
|
||||
@@ -887,7 +888,7 @@ describe('loginUser — credential branches', () => {
|
||||
|
||||
it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:JBSWY3DPEHPK3PXP' });
|
||||
const { password } = { password: undefined };
|
||||
void password;
|
||||
const result = await svc.loginUser({ email: user.email, password: 'TestPass' });
|
||||
@@ -923,7 +924,7 @@ describe('deleteAccount', () => {
|
||||
it('AUTH-DB-067: deletes a regular user row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined();
|
||||
expect(await findRow(orm, Users, { id: user.id })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -942,11 +943,11 @@ describe('updateAppSettings', () => {
|
||||
|
||||
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
||||
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.auditSummary).toMatchObject({ require_mfa: true });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'require_mfa' });
|
||||
});
|
||||
|
||||
it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => {
|
||||
@@ -959,13 +960,13 @@ describe('updateAppSettings', () => {
|
||||
// The reminder crons read their gates per tick now, so a settings change no
|
||||
// longer flags (or needs) a scheduler restart.
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run();
|
||||
await appSetting('smtp_pass', 'stored');
|
||||
const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' });
|
||||
expect(result.success).toBe(true);
|
||||
const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string };
|
||||
const value = await readAppSetting(orm, 'smtp_pass');
|
||||
expect(value).toBe('stored'); // sentinel never overwrites the secret
|
||||
expect(result.auditDebugDetails).not.toHaveProperty('smtp_pass', 'stored');
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key IN ('smtp_pass','notification_channels')").run();
|
||||
await deleteRows(orm, AppSettings, { key: { $in: ['smtp_pass', 'notification_channels'] } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -977,7 +978,7 @@ describe('MFA success flows', () => {
|
||||
const result = await svc.enableMfa(user.id, code);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backup_codes).toHaveLength(10);
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.mfa_enabled).toBe(1);
|
||||
expect(row.mfa_secret).toBe('enc:' + setup.secret);
|
||||
});
|
||||
@@ -991,10 +992,10 @@ describe('MFA success flows', () => {
|
||||
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||
const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) });
|
||||
expect(result).toEqual({ success: true, mfa_enabled: false });
|
||||
const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.mfa_enabled).toBe(0);
|
||||
expect(row.mfa_secret).toBeNull();
|
||||
});
|
||||
@@ -1002,7 +1003,7 @@ describe('MFA success flows', () => {
|
||||
it('AUTH-DB-079: disableMfa answers 401 on a wrong password', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||
expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))
|
||||
.toEqual({ error: 'Incorrect password', status: 401 });
|
||||
});
|
||||
@@ -1010,7 +1011,7 @@ describe('MFA success flows', () => {
|
||||
it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
expect(interstitial.mfa_required).toBe(true);
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) });
|
||||
@@ -1023,12 +1024,13 @@ describe('MFA success flows', () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const codes = ['AAAA-1111', 'BBBB-2222'];
|
||||
// hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode.
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), '2000-01-01T00:00:00.000Z', user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, {
|
||||
mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify(codes.map(hashBackupCode)), updated_at: '2000-01-01T00:00:00.000Z',
|
||||
});
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' });
|
||||
expect(typeof result.token).toBe('string');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes, updated_at FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; updated_at: string };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out
|
||||
// AU33 (setBackupCodesAndTouch, F3 task-5-review-security.md): the backup-code
|
||||
// login branch DOES stamp updated_at, unlike resetPassword's AU44 below.
|
||||
@@ -1046,7 +1048,7 @@ describe('resetPassword', () => {
|
||||
expect(issued.reason).toBe('issued');
|
||||
const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' });
|
||||
expect(result).toEqual({ success: true, userId: user.id });
|
||||
const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.password_version).toBe(1);
|
||||
// token is burned — a second use answers the bespoke 400
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' }))
|
||||
@@ -1057,21 +1059,21 @@ describe('resetPassword', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
await tokens.createMcpToken(user.id, 'cli');
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ success: true, userId: user.id });
|
||||
|
||||
expect((testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id) as { c: number }).c).toBe(0);
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(pushDeviceCount(other.id)).toBe(1);
|
||||
expect(await countRows(orm, McpTokens, { user: user.id })).toBe(0);
|
||||
expect(await pushDeviceCount(user.id)).toBe(0);
|
||||
expect(await pushDeviceCount(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('AUTH-DB-082c: drops the push devices in the same transaction that burns the link, so a failure keeps the link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
|
||||
try {
|
||||
@@ -1080,13 +1082,13 @@ describe('resetPassword', () => {
|
||||
testDb.exec('DROP TRIGGER boom');
|
||||
}
|
||||
|
||||
expect(pushDeviceCount(user.id)).toBe(1);
|
||||
const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number };
|
||||
expect(await pushDeviceCount(user.id)).toBe(1);
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.password_version).toBe(0);
|
||||
// Nothing was burned, so the same link still completes the reset.
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ success: true, userId: user.id });
|
||||
expect(pushDeviceCount(user.id)).toBe(0);
|
||||
expect(await pushDeviceCount(user.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => {
|
||||
@@ -1097,7 +1099,7 @@ describe('resetPassword', () => {
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id);
|
||||
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: '2000-01-01T00:00:00.000Z' });
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
||||
});
|
||||
@@ -1105,8 +1107,7 @@ describe('resetPassword', () => {
|
||||
it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('CCCC-3333')]) });
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
// no code → mfa_required interstitial, token NOT burned
|
||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
||||
@@ -1136,24 +1137,24 @@ describe('resetPassword', () => {
|
||||
setBackupCodesAndTouchSpy.mockRestore();
|
||||
}
|
||||
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-084b: the block is atomic — a failing mcp_tokens prune rolls the whole reset-password block back (F2, task-5-review-security.md, USER-CLEANUP-009 pattern)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const issued = await svc.requestPasswordReset(user.email, null);
|
||||
const hashBefore = (testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as { password_hash: string }).password_hash;
|
||||
const hashBefore = (await readUser(orm, user.id)).password_hash;
|
||||
|
||||
const mcpTokensRepo = await createTestMcpTokensRepo(testDb);
|
||||
const deleteAllForUserSpy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
await expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).rejects.toThrow('boom');
|
||||
|
||||
const row = testDb.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(user.id) as { password_hash: string; password_version: number };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.password_hash).toBe(hashBefore); // old hash unchanged
|
||||
expect(row.password_version).toBe(0);
|
||||
const tokenRow = testDb.prepare('SELECT consumed_at FROM password_reset_tokens WHERE user_id = ?').get(user.id) as { consumed_at: string | null };
|
||||
const tokenRow = await findRow(orm, PasswordResetTokens, { user: user.id });
|
||||
expect(tokenRow.consumed_at).toBeNull(); // the reset token was never committed as consumed
|
||||
} finally {
|
||||
deleteAllForUserSpy.mockRestore();
|
||||
@@ -1197,28 +1198,27 @@ describe('auth quirk fixes', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const invite = createInviteToken(testDb, { max_uses: 5 });
|
||||
testDb.prepare('UPDATE invite_tokens SET trip_id = ? WHERE id = ?').run(trip.id, invite.id);
|
||||
await updateRows(orm, InviteTokens, { id: invite.id }, { trip: trip.id });
|
||||
joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); });
|
||||
|
||||
const result = await svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token });
|
||||
|
||||
expect(result).toEqual({ error: 'Error creating user', status: 500 });
|
||||
expect(testDb.prepare("SELECT id FROM users WHERE email = 'rollback@x.com'").get()).toBeUndefined();
|
||||
const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||
expect(await findRow(orm, Users, { email: 'rollback@x.com' })).toBeNull();
|
||||
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
|
||||
expect(used_count).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const secret = authenticator.generateSecret();
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?')
|
||||
.run('enc:' + secret, JSON.stringify([hashBackupCode('DDDD-4444')]), user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('DDDD-4444')]) });
|
||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||
|
||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' });
|
||||
|
||||
expect(typeof result.token).toBe('string');
|
||||
const row = testDb.prepare('SELECT mfa_backup_codes, login_count, last_login FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; login_count: number; last_login: string | null };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0);
|
||||
expect(row.login_count).toBe(1);
|
||||
expect(row.last_login).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user