krona-staff/tests/integration/portal.test.ts
KAgent 89621ea069 Checkpoint interrupted develop for task staff-prod-ready-0930
Task: staff-prod-ready-0930
Run: 4ea6adb5-b850-4f90-ad7b-74f92749b406
2026-09-30 10:42:45 +00:00

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();
});
});