krona-staff/tests/integration/portal.test.ts

208 lines
7.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js';
const base = {
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
LOG_LEVEL: 'silent',
NODE_ENV: 'test',
} as const;
const managerToken = 'm'.repeat(64);
const terminalToken = 't'.repeat(64);
const tokenConfig = {
STANDALONE_MANAGER_TOKEN: managerToken,
STANDALONE_TERMINAL_TOKEN: terminalToken,
} as const;
function fakeDatabase(rows: Array<Array<Record<string, unknown>>>) {
const calls: Array<[string, readonly unknown[] | undefined]> = [];
const query = async (sql: string, parameters?: readonly unknown[]) => {
calls.push([sql, parameters]);
const resultRows = rows.shift() ?? [];
return { rows: resultRows, rowCount: resultRows.length };
};
return {
calls,
database: {
ping: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
tenantTransaction: async (
_tenant: string,
_actor: string,
run: (client: { query: typeof query }) => Promise<unknown>,
) => run({ query }),
} as never,
};
}
describe('attendance portal', () => {
it('requires authentication when standalone tokens are configured', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject('/api/v1/staff/portal');
expect(response.statusCode).toBe(401);
await app.close();
});
it('creates an HttpOnly manager session and accepts its secure cookie', async () => {
const { database } = fakeDatabase([[], [], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const login = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: managerToken },
});
const setCookie = login.headers['set-cookie'];
expect(login.statusCode).toBe(200);
expect(setCookie).toContain('__Host-krona_staff_session=');
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('Secure');
expect(setCookie).toContain('SameSite=Strict');
const response = await app.inject({
url: '/api/v1/staff/portal',
headers: { cookie: String(setCookie).split(';')[0] },
});
expect(response.statusCode).toBe(200);
expect(response.json<{ viewer: { role: string } }>().viewer.role).toBe('manager');
await app.close();
});
it('does not allow the terminal key to create a browser session', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/session',
payload: { access_token: terminalToken },
});
expect(response.statusCode).toBe(403);
expect(response.headers['set-cookie']).toBeUndefined();
await app.close();
});
it('scopes an employee portal to the linked Core user', async () => {
const { database, calls } = fakeDatabase([[], [], []]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
const response = await app.inject('/api/v1/staff/portal');
expect(response.statusCode).toBe(200);
const body = response.json<{ viewer: { role: string } }>();
expect(body.viewer.role).toBe('employee');
expect(calls).toHaveLength(3);
expect(calls.every((call) => call[1]?.[0] === false && call[1]?.[1] === base.STANDALONE_ACTOR_ID)).toBe(true);
await app.close();
});
it('writes a terminal event and its outbox notification atomically', async () => {
const eventId = '01J00000000000000000000002';
const employeeId = '01J00000000000000000000003';
const { database, calls } = fakeDatabase([[], [{ id: eventId }], []]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'terminal' }), database });
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/attendance/events',
payload: { event_id: eventId, employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', source_event_id: eventId },
});
expect(response.statusCode).toBe(201);
expect(calls).toHaveLength(3);
expect(calls[0]?.[0]).toContain('staff_employees');
expect(calls[2]?.[0]).toContain('staff_outbox');
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
await app.close();
});
it('does not give an employee manager access to the timesheet', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({ config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }), database });
const response = await app.inject({ method: 'PUT', url: '/api/v1/staff/timesheets/01J00000000000000000000003/2026-09-24', payload: { minutes_worked: 480, status: 'approved' } });
expect(response.statusCode).toBe(403);
await app.close();
});
it('allows a manager to create an employee and writes an audit record', async () => {
const employeeId = '01J00000000000000000000004';
const { database, calls } = fakeDatabase([[{
id: employeeId,
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
status: 'active',
}]]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/employees',
headers: { authorization: `Bearer ${managerToken}` },
payload: {
personnel_number: 'A-17',
display_name: 'Анна Орлова',
position: 'Администратор',
gender: 'female',
},
});
expect(response.statusCode).toBe(201);
expect(calls).toHaveLength(2);
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
expect(calls[1]?.[0]).toContain('staff_audit_log');
await app.close();
});
it('does not allow an employee to create another employee', async () => {
const { database } = fakeDatabase([]);
const app = await buildApp({
config: loadConfig({ ...base, STANDALONE_ACCESS: 'employee' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/employees',
payload: { personnel_number: 'A-18', display_name: 'Посторонний' },
});
expect(response.statusCode).toBe(403);
await app.close();
});
it('updates an employee with before and after values in the audit trail', async () => {
const employeeId = '01J00000000000000000000004';
const before = { id: employeeId, core_user_id: null, personnel_number: 'A-17', display_name: 'Анна Орлова', position: 'Администратор', gender: 'female', status: 'active' };
const after = { ...before, position: 'Старший администратор' };
const { database, calls } = fakeDatabase([[before], [after], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/staff/employees/${employeeId}`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { position: 'Старший администратор' },
});
expect(response.statusCode).toBe(200);
expect(calls).toHaveLength(3);
expect(calls[0]?.[0]).toContain('FOR UPDATE');
expect(calls[2]?.[1]?.[4]).toEqual(before);
expect(calls[2]?.[1]?.[5]).toEqual(after);
await app.close();
});
});