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

232 lines
8.3 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { expect, test } from './fixtures';
import { ENTITY_TYPE, UNKNOWN_UUID } from './helpers/constants';
import { dateRange } from './helpers/dates';
import { deleteLink, uniqueName, uniqueSlug } from './helpers/entities';
test.describe('Links', () => {
test.describe.configure({ mode: 'serial' });
let linkId = '';
let slug = '';
let shareId = '';
test.afterAll(async ({ admin }) => {
if (shareId) {
await admin.del(`/api/share/id/${shareId}`);
}
if (linkId) {
await deleteLink(admin, linkId);
}
});
test('POST /api/links creates a link for the current user', async ({ admin, seed }) => {
const name = uniqueName('link');
slug = uniqueSlug('link');
const response = await admin.post('/api/links', { name, url: 'https://umami.is/', slug });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: expect.any(String),
name,
url: 'https://umami.is/',
slug,
userId: seed.admin.id,
teamId: null,
createdAt: expect.any(String),
deletedAt: null,
});
linkId = response.body.id;
});
test('POST /api/links accepts a fixed id and a team', async ({ admin, seed }) => {
const id = randomUUID();
const response = await admin.post('/api/links', {
id,
name: uniqueName('team-link'),
url: 'https://umami.is/',
slug: uniqueSlug('team'),
teamId: seed.team.id,
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id, teamId: seed.team.id, userId: null });
await deleteLink(admin, id);
});
test('POST /api/links validates the body and requires permission', async ({
admin,
viewer,
api,
}) => {
const shortSlug = await admin.post('/api/links', {
name: 'x',
url: 'https://umami.is/',
slug: 'short',
});
const missingUrl = await admin.post('/api/links', { name: 'x', slug: uniqueSlug('link') });
const denied = await viewer.post('/api/links', {
name: 'x',
url: 'https://umami.is/',
slug: uniqueSlug('link'),
});
const anonymous = await api.post('/api/links', {
name: 'x',
url: 'https://umami.is/',
slug: uniqueSlug('link'),
});
expect(shortSlug.status).toBe(400);
expect(shortSlug.body.error.code).toBe('bad-request');
expect(missingUrl.status).toBe(400);
expect(denied.status).toBe(401);
expect(anonymous.status).toBe(401);
});
test('GET /api/links lists the current user links', async ({ admin, user, viewer }) => {
const response = await admin.get('/api/links');
const search = await admin.get('/api/links', { params: { search: slug } });
const sorted = await admin.get('/api/links', {
params: { orderBy: 'createdAt', sortDescending: 'true', pageSize: 1 },
});
const invalidSort = await admin.get('/api/links', { params: { sortDescending: 'yes' } });
const other = await user.get('/api/links');
const none = await viewer.get('/api/links');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
data: expect.any(Array),
count: expect.any(Number),
page: 1,
pageSize: expect.any(Number),
});
expect(response.body.data.map((l: any) => l.id)).toContain(linkId);
expect(search.body.data.map((l: any) => l.id)).toEqual([linkId]);
expect(sorted.status).toBe(200);
expect(sorted.body.data).toHaveLength(1);
expect(invalidSort.status).toBe(400);
expect(other.body.data.map((l: any) => l.id)).not.toContain(linkId);
expect(none.status).toBe(200);
expect(none.body.data).toHaveLength(0);
});
test('GET /api/links/{linkId} returns a link', async ({ admin, user, viewer, api }) => {
const response = await admin.get(`/api/links/${linkId}`);
const denied = await viewer.get(`/api/links/${linkId}`);
const anonymous = await api.get(`/api/links/${linkId}`);
const unknown = await user.get(`/api/links/${UNKNOWN_UUID}`);
// Admins bypass the ownership check, so an unknown id yields an empty 200.
const adminUnknown = await admin.get(`/api/links/${UNKNOWN_UUID}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: linkId, slug });
expect(denied.status).toBe(401);
expect(anonymous.status).toBe(401);
expect(unknown.status).toBe(401);
expect(adminUnknown.status).toBe(200);
expect(adminUnknown.body).toBeNull();
});
test('POST /api/links/{linkId} updates a link', async ({ admin, user, seed }) => {
const name = uniqueName('renamed');
slug = uniqueSlug('renamed');
const response = await admin.post(`/api/links/${linkId}`, {
name,
slug,
url: 'https://umami.is/docs',
});
const duplicate = await admin.post(`/api/links/${linkId}`, { slug: seed.link.slug });
const denied = await user.post(`/api/links/${linkId}`, { name });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: linkId, name, slug, url: 'https://umami.is/docs' });
expect(duplicate.status).toBe(400);
expect(duplicate.body.error.message).toBe('That slug is already taken.');
expect(denied.status).toBe(401);
});
test('POST /api/links/{linkId}/shares creates a link share', async ({ admin, user }) => {
const name = uniqueName('link-share');
const response = await admin.post(`/api/links/${linkId}/shares`, {
name,
parameters: { overview: true },
});
const invalid = await admin.post(`/api/links/${linkId}/shares`, {});
const denied = await user.post(`/api/links/${linkId}/shares`, { name });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
entityId: linkId,
shareType: ENTITY_TYPE.link,
name,
slug: expect.any(String),
parameters: { overview: true },
});
expect(invalid.status).toBe(400);
expect(denied.status).toBe(401);
shareId = response.body.id;
});
test('GET /api/links/{linkId}/shares lists link shares', async ({ admin, viewer, api }) => {
const response = await admin.get(`/api/links/${linkId}/shares`);
const paged = await admin.get(`/api/links/${linkId}/shares`, {
params: { page: 1, pageSize: 1 },
});
const denied = await viewer.get(`/api/links/${linkId}/shares`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ data: expect.any(Array), count: 1, page: 1 });
expect(response.body.data[0]).toMatchObject({ id: shareId, entityId: linkId });
expect(paged.body.pageSize).toBe(1);
expect(denied.status).toBe(401);
// The share resolves publicly to a link-typed token.
const resolved = await api.get(`/api/share/${response.body.data[0].slug}`);
expect(resolved.status).toBe(200);
expect(resolved.body).toMatchObject({ shareType: ENTITY_TYPE.link, linkId, websiteId: linkId });
});
test('GET /api/links/charts returns chart data for accessible links', async ({
admin,
viewer,
seed,
}) => {
const response = await admin.get('/api/links/charts', {
params: { ids: `${seed.link.id},${linkId}`, ...dateRange(seed), timezone: 'UTC' },
});
const defaultRange = await admin.get('/api/links/charts', { params: { ids: seed.link.id } });
const denied = await viewer.get('/api/links/charts', { params: { ids: seed.link.id } });
const invalid = await admin.get('/api/links/charts', { params: { ids: 'nope' } });
const missing = await admin.get('/api/links/charts');
expect(response.status).toBe(200);
// `data` is keyed by link id; ids the caller cannot view are silently dropped.
expect(Object.keys(response.body.data)).toContain(seed.link.id);
expect(response.body.data[seed.link.id]).toBeTruthy();
expect(defaultRange.status).toBe(200);
expect(denied.status).toBe(200);
expect(denied.body.data).toEqual({});
expect(invalid.status).toBe(400);
expect(missing.status).toBe(400);
});
test('DELETE /api/links/{linkId} deletes a link', async ({ admin, user }) => {
const denied = await user.del(`/api/links/${linkId}`);
const response = await admin.del(`/api/links/${linkId}`);
const gone = await admin.get(`/api/links/${linkId}`);
expect(denied.status).toBe(401);
expect(response.status).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(gone.status).toBe(200);
expect(gone.body).toBeNull();
linkId = '';
});
});