Files
umami/tests/api/admin.spec.ts

446 lines
17 KiB
TypeScript

import { expect, test } from './fixtures';
import { login } from './helpers/auth';
import { UNKNOWN_UUID } from './helpers/constants';
import { createTeam, createUser, deleteTeam, deleteUser } from './helpers/entities';
test.describe('Admin', () => {
test.describe('Listings', () => {
test('GET /api/admin/users lists every user', async ({ admin, seed }) => {
const response = await admin.get('/api/admin/users');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
data: expect.any(Array),
count: expect.any(Number),
page: 1,
pageSize: expect.any(Number),
});
const ids = response.body.data.map((u: any) => u.id);
expect(ids).toContain(seed.admin.id);
expect(ids).toContain(seed.user.id);
expect(ids).toContain(seed.viewer.id);
const adminUser = response.body.data.find((u: any) => u.id === seed.admin.id);
expect(adminUser).toMatchObject({
username: seed.admin.username,
role: 'admin',
twoFactorRequired: expect.any(Boolean),
_count: { websites: expect.any(Number) },
});
expect(adminUser).not.toHaveProperty('password');
});
test('GET /api/admin/users supports search, sorting and paging', async ({ admin, seed }) => {
const search = await admin.get('/api/admin/users', { params: { search: 'api-' } });
const byUsername = await admin.get('/api/admin/users', {
params: { orderBy: 'username', sortDescending: 'false' },
});
const byRole = await admin.get('/api/admin/users', {
params: { orderBy: 'role', sortDescending: 'true' },
});
const byCreated = await admin.get('/api/admin/users', { params: { orderBy: 'createdAt' } });
const paged = await admin.get('/api/admin/users', { params: { page: 1, pageSize: 2 } });
const badSort = await admin.get('/api/admin/users', { params: { sortDescending: 'yes' } });
const badPage = await admin.get('/api/admin/users', { params: { pageSize: 'ten' } });
expect(search.status).toBe(200);
expect(search.body.data.length).toBeGreaterThanOrEqual(2);
expect(search.body.data.map((u: any) => u.id)).toContain(seed.user.id);
expect(search.body.data.every((u: any) => u.username.includes('api-'))).toBe(true);
expect(search.body.data.map((u: any) => u.id)).not.toContain(seed.admin.id);
expect(byUsername.status).toBe(200);
const usernames = byUsername.body.data.map((u: any) => u.username);
expect(usernames).toEqual([...usernames].sort());
expect(byRole.status).toBe(200);
expect(byCreated.status).toBe(200);
expect(paged.status).toBe(200);
expect(paged.body.pageSize).toBe(2);
expect(paged.body.data.length).toBeLessThanOrEqual(2);
expect(badSort.status).toBe(400);
expect(badPage.status).toBe(400);
});
test('GET /api/admin/users requires an admin session', async ({
api,
user,
viewer,
apiKey,
}) => {
expect((await api.get('/api/admin/users')).status).toBe(401);
expect((await user.get('/api/admin/users')).status).toBe(401);
expect((await viewer.get('/api/admin/users')).status).toBe(401);
// API keys are blocked on /api/admin/* even for admins.
expect((await apiKey.get('/api/admin/users')).status).toBe(401);
});
test('GET /api/admin/websites lists every website with its owner', async ({ admin, seed }) => {
const response = await admin.get('/api/admin/websites');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
const ids = response.body.data.map((w: any) => w.id);
expect(ids).toContain(seed.website.id);
expect(ids).toContain(seed.website2.id);
const website2 = response.body.data.find((w: any) => w.id === seed.website2.id);
expect(website2).toMatchObject({
name: seed.website2.name,
domain: seed.website2.domain,
user: { id: seed.user.id, username: seed.user.username },
});
expect(website2).toHaveProperty('shareId');
});
test('GET /api/admin/websites supports search, sorting and paging', async ({ admin, seed }) => {
const byDomain = await admin.get('/api/admin/websites', {
params: { search: seed.website2.domain },
});
const byName = await admin.get('/api/admin/websites', {
params: { search: seed.website.name },
});
const sortedName = await admin.get('/api/admin/websites', {
params: { orderBy: 'name', sortDescending: 'false', pageSize: 5 },
});
const sortedDomain = await admin.get('/api/admin/websites', {
params: { orderBy: 'domain', sortDescending: 'true' },
});
const sortedCreated = await admin.get('/api/admin/websites', {
params: { orderBy: 'createdAt' },
});
const badSort = await admin.get('/api/admin/websites', { params: { sortDescending: 'x' } });
expect(byDomain.status).toBe(200);
expect(byDomain.body.data.map((w: any) => w.id)).toContain(seed.website2.id);
expect(byDomain.body.data.map((w: any) => w.id)).not.toContain(seed.website.id);
expect(byName.status).toBe(200);
expect(byName.body.data.map((w: any) => w.id)).toContain(seed.website.id);
expect(sortedName.status).toBe(200);
expect(sortedName.body.pageSize).toBe(5);
expect(sortedName.body.data.length).toBeLessThanOrEqual(5);
expect(sortedDomain.status).toBe(200);
expect(sortedCreated.status).toBe(200);
expect(badSort.status).toBe(400);
});
test('GET /api/admin/websites requires an admin session', async ({ api, user, apiKey }) => {
expect((await api.get('/api/admin/websites')).status).toBe(401);
expect((await user.get('/api/admin/websites')).status).toBe(401);
expect((await apiKey.get('/api/admin/websites')).status).toBe(401);
});
test('GET /api/admin/teams lists every team with members', async ({ admin, seed }) => {
const response = await admin.get('/api/admin/teams');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ data: expect.any(Array), count: expect.any(Number) });
const team = response.body.data.find((t: any) => t.id === seed.team.id);
expect(team).toMatchObject({
name: seed.team.name,
members: expect.any(Array),
_count: { websites: expect.any(Number), members: expect.any(Number) },
});
expect(team.members.map((m: any) => m.userId)).toContain(seed.user.id);
expect(team.members[0]).toMatchObject({
role: expect.any(String),
user: { id: expect.any(String), username: expect.any(String) },
});
});
test('GET /api/admin/teams supports search, sorting and paging', async ({ admin, seed }) => {
const search = await admin.get('/api/admin/teams', { params: { search: seed.team.name } });
const none = await admin.get('/api/admin/teams', {
params: { search: 'no-such-team-name-xyz' },
});
const sorted = await admin.get('/api/admin/teams', {
params: { orderBy: 'name', sortDescending: 'true', page: 1, pageSize: 1 },
});
const badSort = await admin.get('/api/admin/teams', { params: { sortDescending: 'nope' } });
expect(search.status).toBe(200);
expect(search.body.data.map((t: any) => t.id)).toContain(seed.team.id);
expect(none.status).toBe(200);
expect(none.body.data).toEqual([]);
expect(none.body.count).toBe(0);
expect(sorted.status).toBe(200);
expect(sorted.body.pageSize).toBe(1);
expect(sorted.body.data.length).toBe(1);
expect(badSort.status).toBe(400);
});
test('GET /api/admin/teams requires an admin session', async ({ api, user, apiKey }) => {
expect((await api.get('/api/admin/teams')).status).toBe(401);
expect((await user.get('/api/admin/teams')).status).toBe(401);
expect((await apiKey.get('/api/admin/teams')).status).toBe(401);
});
});
test.describe('Global 2FA enforcement', () => {
test.describe.configure({ mode: 'serial' });
test.afterAll(async ({ admin }) => {
// Never leave the global requirement enabled for other specs.
await admin.post('/api/admin/2fa/global', { required: false });
});
test('POST /api/admin/2fa/global validates the body', async ({ admin }) => {
const stringValue = await admin.post('/api/admin/2fa/global', { required: 'true' });
const missing = await admin.post('/api/admin/2fa/global', {});
expect(stringValue.status).toBe(400);
expect(missing.status).toBe(400);
});
test('POST /api/admin/2fa/global requires an admin session', async ({ api, user, apiKey }) => {
expect((await api.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
expect((await user.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
expect((await apiKey.post('/api/admin/2fa/global', { required: false })).status).toBe(401);
});
test('POST /api/admin/2fa/global toggles the global requirement', async ({ admin, user }) => {
const before = await user.get('/api/2fa/status');
expect(before.status).toBe(200);
expect(before.body.globalRequired).toBe(false);
const enabled = await admin.post('/api/admin/2fa/global', { required: true });
try {
expect(enabled.status).toBe(200);
expect(enabled.body).toEqual({ ok: true, required: true });
const status = await user.get('/api/2fa/status');
expect(status.status).toBe(200);
expect(status.body).toMatchObject({
isRequired: true,
requiredReason: 'global',
isConfigured: true,
globalRequired: true,
});
} finally {
const disabled = await admin.post('/api/admin/2fa/global', { required: false });
expect(disabled.status).toBe(200);
expect(disabled.body).toEqual({ ok: true, required: false });
}
const after = await user.get('/api/2fa/status');
expect(after.body).toMatchObject({ globalRequired: false });
expect(after.body.requiredReason).not.toBe('global');
});
});
test.describe('Team 2FA enforcement', () => {
test.describe.configure({ mode: 'serial' });
let teamId = '';
test.beforeAll(async ({ admin }) => {
teamId = (await createTeam(admin)).id;
});
test.afterAll(async ({ admin }) => {
if (teamId) {
await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false });
await deleteTeam(admin, teamId);
}
});
test('POST /api/admin/teams/{teamId}/2fa validates the body', async ({ admin }) => {
const stringValue = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: 'true' });
const missing = await admin.post(`/api/admin/teams/${teamId}/2fa`, {});
expect(stringValue.status).toBe(400);
expect(missing.status).toBe(400);
});
test('POST /api/admin/teams/{teamId}/2fa requires an admin session', async ({
api,
user,
apiKey,
}) => {
const body = { required: false };
expect((await api.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
expect((await user.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
expect((await apiKey.post(`/api/admin/teams/${teamId}/2fa`, body)).status).toBe(401);
});
test('POST /api/admin/teams/{teamId}/2fa toggles the team requirement', async ({ admin }) => {
const enabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: true });
try {
expect(enabled.status).toBe(200);
expect(enabled.body).toEqual({ ok: true, teamId, twoFactorRequired: true });
// The admin owns the throwaway team, so their status now reports the team reason.
const status = await admin.get('/api/2fa/status');
expect(status.status).toBe(200);
expect(status.body).toMatchObject({ isRequired: true, requiredReason: 'team' });
} finally {
const disabled = await admin.post(`/api/admin/teams/${teamId}/2fa`, { required: false });
expect(disabled.status).toBe(200);
expect(disabled.body).toEqual({ ok: true, teamId, twoFactorRequired: false });
}
const after = await admin.get('/api/2fa/status');
expect(after.body.requiredReason).not.toBe('team');
});
});
test.describe('User 2FA enforcement', () => {
test.describe.configure({ mode: 'serial' });
let userId = '';
let userToken = '';
test.beforeAll(async ({ admin, api }) => {
const created = await createUser(admin);
userId = created.id;
userToken = await login(api, { username: created.username, password: created.password });
});
test.afterAll(async ({ admin }) => {
if (userId) {
await deleteUser(admin, userId);
}
});
test('GET /api/admin/users/{userId}/2fa reports whether 2FA is enabled', async ({ admin }) => {
const response = await admin.get(`/api/admin/users/${userId}/2fa`);
const unknown = await admin.get(`/api/admin/users/${UNKNOWN_UUID}/2fa`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ isEnabled: false });
// Unknown users simply have no 2FA record.
expect(unknown.status).toBe(200);
expect(unknown.body).toEqual({ isEnabled: false });
});
test('GET /api/admin/users/{userId}/2fa requires an admin session', async ({
api,
user,
apiKey,
}) => {
expect((await api.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
expect((await user.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
expect((await apiKey.get(`/api/admin/users/${userId}/2fa`)).status).toBe(401);
});
test('POST /api/admin/users/{userId}/2fa validates the body', async ({ admin }) => {
const stringValue = await admin.post(`/api/admin/users/${userId}/2fa`, { required: 'true' });
const missing = await admin.post(`/api/admin/users/${userId}/2fa`, {});
expect(stringValue.status).toBe(400);
expect(missing.status).toBe(400);
});
test('POST /api/admin/users/{userId}/2fa requires an admin session', async ({
api,
user,
apiKey,
}) => {
const body = { required: false };
expect((await api.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
expect((await user.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
expect((await apiKey.post(`/api/admin/users/${userId}/2fa`, body)).status).toBe(401);
});
test('POST /api/admin/users/{userId}/2fa toggles the user requirement', async ({
admin,
api,
}) => {
const throwaway = api.bearer(userToken);
const enabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: true });
expect(enabled.status).toBe(200);
expect(enabled.body).toEqual({ ok: true, userId, twoFactorRequired: true });
const status = await throwaway.get('/api/2fa/status');
const me = await throwaway.get('/api/me');
const listed = await admin.get('/api/admin/users', { params: { search: userId } });
expect(status.body).toMatchObject({
isEnabled: false,
isRequired: true,
requiredReason: 'user',
globalRequired: false,
});
expect(me.body.user.twoFactorRequired).toBe(true);
expect(listed.status).toBe(200);
const disabled = await admin.post(`/api/admin/users/${userId}/2fa`, { required: false });
expect(disabled.status).toBe(200);
expect(disabled.body).toEqual({ ok: true, userId, twoFactorRequired: false });
const after = await throwaway.get('/api/2fa/status');
expect(after.body).toMatchObject({ isRequired: false, requiredReason: null });
});
test('DELETE /api/admin/users/{userId}/2fa resets the user 2FA state', async ({
admin,
api,
user,
apiKey,
}) => {
const throwaway = api.bearer(userToken);
// Leave a pending setup behind so the reset has something to clear.
const initiated = await throwaway.post('/api/2fa/setup/initiate');
expect(initiated.status).toBe(200);
const denied = await user.del(`/api/admin/users/${userId}/2fa`);
const viaApiKey = await apiKey.del(`/api/admin/users/${userId}/2fa`);
const response = await admin.del(`/api/admin/users/${userId}/2fa`);
const again = await admin.del(`/api/admin/users/${userId}/2fa`);
expect(denied.status).toBe(401);
expect(viaApiKey.status).toBe(401);
expect(response.status).toBe(200);
expect(response.body).toEqual({
ok: true,
userId,
reset: {
twoFactorAuth: 1,
backupCodes: expect.any(Number),
otpUsed: expect.any(Number),
rateLimit: expect.any(Number),
},
});
expect(again.status).toBe(200);
expect(again.body.reset).toEqual({
twoFactorAuth: 0,
backupCodes: 0,
otpUsed: 0,
rateLimit: 0,
});
// The pending setup is gone.
const confirm = await throwaway.post('/api/2fa/setup/confirm', { token: '000000' });
expect(confirm.status).toBe(400);
expect(confirm.body.error.code).toBe('two-factor-error-no-pending-setup');
});
});
});