387 lines
13 KiB
TypeScript
387 lines
13 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: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
|
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('rate-limits repeated failed browser logins', async () => {
|
|
const { database } = fakeDatabase([]);
|
|
const app = await buildApp({
|
|
config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }),
|
|
database,
|
|
});
|
|
|
|
let response;
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/staff/session',
|
|
payload: { access_token: 'x'.repeat(64) },
|
|
});
|
|
expect(response.statusCode).toBe(401);
|
|
}
|
|
|
|
response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/staff/session',
|
|
payload: { access_token: 'x'.repeat(64) },
|
|
});
|
|
expect(response.statusCode).toBe(429);
|
|
expect(response.headers['retry-after']).toBeDefined();
|
|
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: employeeId }], [{ 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("status = 'active'");
|
|
expect(calls[2]?.[0]).toContain('staff_outbox');
|
|
expect(calls[2]?.[1]?.[2]).toBe('attendance.clock.in');
|
|
await app.close();
|
|
});
|
|
|
|
it('rejects an unknown terminal employee without writing attendance or outbox', async () => {
|
|
const eventId = '01J00000000000000000000002';
|
|
const employeeId = '01J00000000000000000000003';
|
|
const { database, calls } = fakeDatabase([[]]);
|
|
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(404);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.[0]).toContain("status = 'active'");
|
|
expect(calls[0]?.[0]).not.toContain('INSERT INTO staff_employees');
|
|
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(3);
|
|
expect(calls[0]?.[0]).toContain('INSERT INTO staff_employees');
|
|
expect(calls[1]?.[0]).toContain('staff_audit_log');
|
|
expect(calls[2]?.[0]).toContain('staff_sync_change_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(5);
|
|
expect(calls[0]?.[0]).toContain('FOR UPDATE');
|
|
expect(calls[3]?.[1]?.[4]).toEqual(before);
|
|
expect(calls[3]?.[1]?.[5]).toEqual(after);
|
|
expect(calls[4]?.[0]).toContain('staff_sync_change_log');
|
|
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();
|
|
});
|
|
});
|