krona-staff/tests/integration/portal.test.ts
2026-09-24 17:00:08 +07:00

254 lines
11 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(4);
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();
});
it('reconciles terminal events into a reviewable work session', async () => {
const employeeId = '01J00000000000000000000004';
const orgUnitId = '01J00000000000000000000005';
const sessionId = '01J00000000000000000000006';
const attendanceRows = [
{ id: '01J00000000000000000000010', employee_id: employeeId, event_type: 'check_in', occurred_at: '2026-09-24T01:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
{ id: '01J00000000000000000000011', employee_id: employeeId, event_type: 'check_out', occurred_at: '2026-09-24T09:00:00.000Z', org_unit_id: orgUnitId, business_date: '2026-09-24', automatic_close_at: '2026-09-24T19:00:00.000Z' },
];
const session = { id: sessionId, employee_id: employeeId, org_unit_id: orgUnitId, business_date: '2026-09-24', worked_minutes: 480, status: 'pending_review', anomaly: null, source_event_ids: attendanceRows.map((row) => row.id), auto_closed: false, revision: 1 };
const { database, calls } = fakeDatabase([attendanceRows, [session], []]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'POST',
url: '/api/v1/staff/work-sessions/reconcile',
headers: { authorization: `Bearer ${managerToken}` },
payload: { date_from: '2026-09-24', date_to: '2026-09-24' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ sessions_created_or_updated: 1, anomalous_sessions: 0, unresolved_events: 0 });
expect(calls[1]?.[0]).toContain('INSERT INTO staff_work_sessions');
expect(calls[1]?.[1]?.[7]).toBe(480);
await app.close();
});
it('requires a reason when confirmed hours are corrected', async () => {
const sessionId = '01J00000000000000000000006';
const current = { id: sessionId, employee_id: '01J00000000000000000000004', org_unit_id: '01J00000000000000000000005', business_date: '2026-09-24', started_at: '2026-09-24T01:00:00.000Z', ended_at: '2026-09-24T09:00:00.000Z', worked_minutes: 480, status: 'pending_review', anomaly: null, correction_note: '', source_event_ids: [], auto_closed: false, revision: 1 };
const { database, calls } = fakeDatabase([[current]]);
const app = await buildApp({
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
database,
});
const response = await app.inject({
method: 'PATCH',
url: `/api/v1/staff/work-sessions/${sessionId}`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { status: 'confirmed', worked_minutes: 450 },
});
expect(response.statusCode).toBe(400);
expect(calls).toHaveLength(1);
await app.close();
});
});