137 lines
5.2 KiB
TypeScript
137 lines
5.2 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]);
|
|
return { rows: rows.shift() ?? [], rowCount: 1 };
|
|
};
|
|
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();
|
|
});
|
|
});
|