446 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|