feat(api): implement authenticated habit tracking and combined calendars
This commit is contained in:
63
src/habits/security.test.ts
Normal file
63
src/habits/security.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import { fixture } from './test-fixture';
|
||||
let f: ReturnType<typeof fixture>;
|
||||
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
|
||||
test('every protected resource route enforces authentication, ownership and mutation origin', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Private', method: 'tasks', tasks: [{ name: 'Secret' }] }, 201);
|
||||
const t = h.tasks[0].id;
|
||||
const chart = await f.json('/charts', 'POST', { name: 'Private chart', habitIds: [h.id] }, 201);
|
||||
const cases: [string, string, any?][] = [
|
||||
[`/habits/${h.id}`, 'GET'], [`/habits/${h.id}`, 'PATCH', { name: 'Stolen' }], [`/habits/${h.id}`, 'DELETE'],
|
||||
[`/habits/${h.id}/history`, 'GET'], [`/habits/${h.id}/tasks`, 'GET'], [`/habits/${h.id}/tasks`, 'POST', { name: 'Bad' }],
|
||||
[`/habits/${h.id}/tasks/${t}`, 'GET'], [`/habits/${h.id}/tasks/${t}`, 'PATCH', { name: 'Bad' }], [`/habits/${h.id}/tasks/${t}`, 'DELETE'],
|
||||
[`/habits/${h.id}/days/2026-09-04`, 'GET'], [`/habits/${h.id}/days/2026-09-04/audit`, 'GET'],
|
||||
[`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', { done: true }], [`/habits/${h.id}/days/2026-09-04/tasks/${t}`, 'PUT', { done: true }],
|
||||
[`/habits/${h.id}/calendar-settings`, 'GET'], [`/habits/${h.id}/calendar-settings`, 'PUT', {}], [`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'],
|
||||
[`/charts/${chart.id}`, 'GET'], [`/charts/${chart.id}`, 'PATCH', { name: 'Bad' }], [`/charts/${chart.id}`, 'DELETE'],
|
||||
[`/charts/${chart.id}/calendar?from=2026-09-04&to=2026-09-05`, 'GET'], [`/charts/${chart.id}/days/2026-09-04`, 'GET'],
|
||||
];
|
||||
for (const [path, method, body] of cases) {
|
||||
const unauthorized = await f.request(path, method, body, 'z'); expect(unauthorized.status).toBe(401);
|
||||
expect(unauthorized.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect((await f.request(path, method, body, 'b')).status).toBe(404);
|
||||
if (method !== 'GET') for (const origin of ['', 'https://evil.example']) expect((await f.request(path, method, body, 'a', { Origin: origin })).status).toBe(403);
|
||||
}
|
||||
for (const path of ['/habits', '/charts', '/today', '/days/2026-09-04']) expect((await f.request(path, 'GET', undefined, 'z')).status).toBe(401);
|
||||
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
|
||||
expect((await f.json('/charts', 'GET', undefined, 200, 'b')).charts).toHaveLength(0);
|
||||
expect((await f.json('/today', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
|
||||
expect((await f.json('/days/2026-09-04', 'GET', undefined, 200, 'b')).habits).toHaveLength(0);
|
||||
expect((await f.request('/charts', 'POST', { name: 'Steal', habitIds: [h.id] }, 'b')).status).toBe(404);
|
||||
for (const path of ['/habits', '/charts', '/me']) {
|
||||
const method = path === '/me' ? 'PATCH' : 'POST';
|
||||
expect((await f.request(path, method, {}, 'z')).status).toBe(401);
|
||||
expect((await f.request(path, method, {}, 'a', { Origin: '' })).status).toBe(403);
|
||||
}
|
||||
});
|
||||
test('strict JSON, dates, bounds, methods, task membership and non-render settings validation', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201);
|
||||
const invalidHabits = [{}, { name: ' ', method: 'manual' }, { name: 'Mixed', method: 'manual', target: 8 }, { name: 'Water', method: 'count', target: -1 }, { name: 'Huge', method: 'count', target: 10001 }, { name: 'Odd', method: 'manual', schedule: { type: 'interval', every: 0, anchor: '2026-09-04' } }];
|
||||
for (const body of invalidHabits) expect((await f.request('/habits', 'POST', body)).status).toBe(422);
|
||||
for (const body of [{ count: -1 }, { count: 1.5 }, { done: true }, { count: 2, done: true }, { count: 1e20 }]) expect((await f.request(`/habits/${h.id}/days/2026-09-04/progress`, 'PUT', body)).status).toBe(422);
|
||||
for (const date of ['2026-02-30', 'garbage', '2026-9-4']) expect((await f.request(`/habits/${h.id}/days/${date}`)).status).toBe(422);
|
||||
for (const query of ['', '?from=2026-09-04', '?from=2026-09-05&to=2026-09-04', '?from=2020-01-01&to=2026-01-01', '?from=2026-09-04&to=2026-09-05&bad=x']) expect((await f.request(`/habits/${h.id}/calendar${query}`)).status).toBe(422);
|
||||
expect((await f.request('/habits?archived=yes')).status).toBe(422);
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', {})).status).toBe(422);
|
||||
expect((await f.request(`/habits/${h.id}`, 'PATCH', { method: 'manual', target: 4 })).status).toBe(422);
|
||||
expect((await f.request(`/habits/${h.id}/tasks`)).status).toBe(409);
|
||||
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'Wrong method' })).status).toBe(409);
|
||||
expect((await f.request(`/habits/${h.id}/days/2026-09-04/tasks/missing`, 'PUT', { done: true })).status).toBe(404);
|
||||
for (const settings of [{ mainColor: 'red' }, { shadeCount: 1 }, { daySpacing: 5 }]) expect((await f.request(`/habits/${h.id}/calendar-settings`, 'PUT', settings)).status).toBe(422);
|
||||
for (const timezone of ['', 'invalid', '+02:00']) expect((await f.request('/me', 'PATCH', { timezone })).status).toBe(422);
|
||||
for (const habitIds of [[], [h.id, h.id], ['not-uuid']]) expect((await f.request('/charts', 'POST', { name: 'Bad', habitIds })).status).toBe(422);
|
||||
const response = await f.app.request(`${f.origin}/api/habits`, { method: 'POST', headers: { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' }, body: '{' }); expect(response.status).toBe(400);
|
||||
expect((await f.request('/habits', 'POST', {}, 'a', { 'Content-Type': 'text/plain' })).status).toBe(400);
|
||||
expect((await f.request('/habits', 'POST', { name: 'x'.repeat(70000) })).status).toBe(413);
|
||||
});
|
||||
test('missing tasks and archived editing fail without partial writes', async () => {
|
||||
const h = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks' }, 201);
|
||||
expect((await f.request(`/habits/${h.id}/tasks/nope`, 'PATCH', { name: 'No' })).status).toBe(404);
|
||||
await f.request(`/habits/${h.id}`, 'DELETE');
|
||||
expect((await f.request(`/habits/${h.id}/tasks`, 'POST', { name: 'No' })).status).toBe(409);
|
||||
expect((await f.json(`/habits/${h.id}/history`)).revisions).toHaveLength(2);
|
||||
});
|
||||
Reference in New Issue
Block a user