Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
527 changes: 468 additions & 59 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { ApplicationsModule } from './applications/applications.module';
import { SharedModule } from './shared/shared.module';
import { MembersModule } from './members/members.module';
import { ProjectsModule } from './projects/projects.module';
import { EventsModule } from './events/events.module';

@Module({
imports: [SharedModule, ApplicationsModule],
imports: [SharedModule, ApplicationsModule, MembersModule, ProjectsModule, EventsModule],
})
export class AppModule {}
106 changes: 106 additions & 0 deletions src/applications/applications.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createDecipheriv } from 'node:crypto';
import { ApplicationsService } from './applications.service';
import { encryptRut, normalizeRut } from './rut-encryption';

describe('ApplicationsService with encrypted RUT', () => {
const previousVersion = process.env.RUT_ENCRYPTION_KEY_VERSION;
const previousKey = process.env.RUT_ENCRYPTION_KEY_V1;
const key = Buffer.alloc(32, 7); // Test-only key, never used outside tests.
const body = {
nombre_completo: 'Persona de prueba', rut: '12.345.678-5',
correo_institucional: 'TEST@utem.cl', carrera: 'Computación', area_interes1: 'Backend',
};
let client: any;
let pool: any;
let service: ApplicationsService;
beforeEach(() => {
process.env.RUT_ENCRYPTION_KEY_VERSION = '1';
process.env.RUT_ENCRYPTION_KEY_V1 = key.toString('base64');
client = { query: jest.fn(async (sql: string) => {
if (sql.includes('SELECT id')) return { rows: [{ id: '2' }] };
if (sql.includes('INSERT INTO')) return { rows: [{ id: '9007199254740993' }] };
return { rows: [] };
}), release: jest.fn() };
pool = { connect: jest.fn(async () => client) };
service = new ApplicationsService(pool);
});
afterAll(() => {
if (previousVersion === undefined) delete process.env.RUT_ENCRYPTION_KEY_VERSION;
else process.env.RUT_ENCRYPTION_KEY_VERSION = previousVersion;
if (previousKey === undefined) delete process.env.RUT_ENCRYPTION_KEY_V1;
else process.env.RUT_ENCRYPTION_KEY_V1 = previousKey;
});
it('lists applications without selecting RUT or encryption metadata', async () => {
const rows = [{ id: '1', periodo_id: '2', nombre_completo: 'Persona de prueba' }];
pool.query = jest.fn().mockResolvedValue({ rows });
await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 1, postulaciones: rows });
const sql = pool.query.mock.calls[0][0];
expect(sql).not.toMatch(/rut|SELECT\s+\*/i);
expect(sql).toContain('estado_postulacion');
expect(sql).toContain('ORDER BY created_at DESC, id DESC');
});
it('returns an empty collection when there are no applications', async () => {
pool.query = jest.fn().mockResolvedValue({ rows: [] });
await expect(service.findAll()).resolves.toEqual({ totalPostulaciones: 0, postulaciones: [] });
});
it('does not expose database errors from the listing', async () => {
pool.query = jest.fn().mockRejectedValue(new Error('private database detail'));
await expect(service.findAll()).rejects.toMatchObject({
status: 500, response: { responseCode: 'E003', message: 'No se pudieron consultar las postulaciones' },
});
});
it('validates the RUT check digit', () => {
expect(normalizeRut('12.345.678-5')).toBe('12345678-5');
expect(normalizeRut('12345678-0')).toBeNull();
});
it('encrypts with randomized authenticated encryption', () => {
const first = encryptRut('12345678-5').ciphertext;
expect(first.equals(encryptRut('12345678-5').ciphertext)).toBe(false);
const decipher = createDecipheriv('aes-256-gcm', key, first.subarray(0, 12));
decipher.setAAD(Buffer.from('exdev:postulaciones:rut:v1'));
decipher.setAuthTag(first.subarray(12, 28));
expect(Buffer.concat([decipher.update(first.subarray(28)), decipher.final()]).toString()).toBe('12345678-5');
});
it('saves only encrypted RUT and selects the period on the server', async () => {
const result = await service.create({ ...body, periodo_id: 999 });
expect(result.idPostulacion).toBe('9007199254740993');
const insert = client.query.mock.calls.find(([sql]) => sql.includes('INSERT INTO'));
expect(insert[1][0]).toBe('2');
expect(Buffer.isBuffer(insert[1][1])).toBe(true);
expect(insert[1]).not.toContain('12345678-5');
expect(insert[1]).toContain('test@utem.cl');
expect(client.query).toHaveBeenCalledWith('COMMIT');
expect(client.release).toHaveBeenCalledWith(false);
});
it('rejects invalid RUT before connecting', async () => {
await expect(service.create({ ...body, rut: '12345678-0' })).rejects.toMatchObject({ status: 400 });
expect(pool.connect).not.toHaveBeenCalled();
});
it('fails closed without a key', async () => {
delete process.env.RUT_ENCRYPTION_KEY_V1;
await expect(service.create(body)).rejects.toMatchObject({ status: 500 });
expect(pool.connect).not.toHaveBeenCalled();
});
it('rejects when no period is enabled', async () => {
client.query.mockImplementation(async () => ({ rows: [] }));
await expect(service.create(body)).rejects.toMatchObject({ status: 409 });
expect(client.query).toHaveBeenCalledWith('ROLLBACK');
});
it('rejects outside the date window after locking', async () => {
client.query.mockImplementation(async (sql: string) => ({ rows: sql.includes('FOR UPDATE') ? [{ id: '2' }] : [] }));
await expect(service.create(body)).rejects.toMatchObject({ status: 409 });
expect(client.query.mock.calls.some(([sql]) => sql.includes('INSERT INTO'))).toBe(false);
});
it('does not expose database details on duplicate email', async () => {
client.query.mockImplementation(async (sql: string) => {
if (sql.includes('INSERT INTO')) throw { code: '23505', detail: 'PRIVATE DATA' };
return { rows: [{ id: '2' }] };
});
try { await service.create(body); throw new Error('Expected failure'); }
catch (error) {
expect(error.getStatus()).toBe(409);
expect(JSON.stringify(error.getResponse())).not.toContain('PRIVATE DATA');
}
expect(client.query).toHaveBeenCalledWith('ROLLBACK');
});
});
229 changes: 127 additions & 102 deletions src/applications/applications.service.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,143 @@
import { Inject, Injectable, InternalServerErrorException, HttpException } from '@nestjs/common';
import { PG_POOL } from 'src/shared/connections/database.module';
import { Pool } from 'pg';
import { buildErrorExceptionPayload } from 'src/common/error-response';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Pool, PoolClient } from 'pg';
import { PG_POOL } from '../shared/connections/database.module';
import { encryptRut, normalizeRut } from './rut-encryption';

function fail(status: number, responseCode: string, message: string): never {
throw new HttpException({ responseCode, message }, status);
}

function validate(body: unknown): Record<string, unknown> {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
fail(400, 'E002', 'Datos inválidos');
}
const input = body as Record<string, unknown>;
const data: Record<string, unknown> = {};
const texts: [string, number, boolean][] = [
['nombre_completo', 200, true], ['correo_institucional', 150, true],
['campus', 50, false], ['carrera', 100, true], ['area_interes1', 80, true],
['area_interes2', 80, false], ['area_interes3', 80, false],
['ayudantias', 1000, false], ['motivo_postulacion', 5000, false],
['proyecto_idea', 5000, false], ['portafolio', 500, false],
['postulacion_conjunta', 500, false], ['pitch', 10000, false], ['apodo', 100, false],
];
for (const [field, max, required] of texts) {
const value = input[field];
if (value != null && typeof value !== 'string') fail(400, 'E002', `Campo inválido: ${field}`);
const text = (value as string | undefined)?.trim() || null;
if ((required && !text) || (text && text.length > max)) fail(400, 'E002', `Campo inválido: ${field}`);
data[field] = text;
}
const email = (data.correo_institucional as string).toLowerCase();
if (!/^[^\s@]+@utem\.cl$/.test(email)) fail(400, 'E002', 'Correo institucional inválido');
data.correo_institucional = email;
const currentYear = Number(new Intl.DateTimeFormat('en', {
year: 'numeric', timeZone: 'America/Santiago',
}).format(new Date()));
for (const [field, min, max] of [
['edad', 16, 99], ['anio_ingreso', currentYear - 10, currentYear],
['anio_actual', 1, 2147483647], ['horas_disponibles_semanales', 0, 2147483647],
] as [string, number, number][]) {
const raw = input[field];
if (raw == null || raw === '') { data[field] = null; continue; }
if (typeof raw !== 'number' && !(typeof raw === 'string' && /^\d+$/.test(raw))) {
fail(400, 'E002', `Campo inválido: ${field}`);
}
const value = Number(raw);
if (!Number.isInteger(value) || value < min || value > max) fail(400, 'E002', `Campo inválido: ${field}`);
data[field] = value;
}
const rut = normalizeRut(input.rut);
if (!rut) fail(400, 'E002', 'RUT inválido');
data.rut = rut;
return data;
}

@Injectable()
export class ApplicationsService {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async create(body: any){
const sql = `
INSERT INTO postulaciones (
nombre_completo,
rut,
edad,
correo_institucional,
campus,
carrera,
anio_ingreso,
anio_actual,
area_interes1,
area_interes2,
area_interes3,
ayudantias,
horas_disponibles_semanales,
motivo_postulacion,
proyecto_idea,
portafolio,
postulacion_conjunta,
pitch,
apodo
)
VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19
)
RETURNING id;
`;

const values = [
body?.nombre_completo ?? null,
body?.rut ?? null,
body?.edad ?? null,
body?.correo_institucional ?? null,
body?.campus ?? null,
body?.carrera ?? null,
body?.anio_ingreso ?? null,
body?.anio_actual ?? null,
body?.area_interes1 ?? null,
body?.area_interes2 ?? null,
body?.area_interes3 ?? null,
body?.ayudantias ?? null,
body?.horas_disponibles_semanales ?? null,
body?.motivo_postulacion ?? null,
body?.proyecto_idea ?? null,
body?.portafolio ?? null,
body?.postulacion_conjunta ?? null,
body?.pitch ?? null,
body?.apodo ?? null,
];
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}

async create(body: unknown) {
const data = validate(body);
let client: PoolClient | undefined;
let transaction = false;
let discardConnection = false;
try {
const { rows } = await this.pool.query(sql, values);
// Fail closed if the encryption key is missing. Never fall back to plaintext.
const encrypted = encryptRut(data.rut as string);
client = await this.pool.connect();
await client.query('BEGIN');
transaction = true;
// The lock serializes this submission against changes/cancellation of the period.
const period = await client.query(`
SELECT id FROM public.periodos_postulacion
WHERE estado_periodo = 'habilitado'
FOR UPDATE
`);
if (period.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto');
const periodId = period.rows[0].id;
// Read the clock AFTER acquiring the lock, not the transaction start time.
const window = await client.query(`
SELECT id FROM public.periodos_postulacion
WHERE id = $1 AND estado_periodo = 'habilitado'
AND fecha_apertura <= clock_timestamp()
AND clock_timestamp() < fecha_cierre
`, [periodId]);
if (window.rows.length !== 1) fail(409, 'E004', 'No hay un período de postulaciones abierto');

const fields = [
'nombre_completo', 'edad', 'correo_institucional', 'campus', 'carrera',
'anio_ingreso', 'anio_actual', 'area_interes1', 'area_interes2', 'area_interes3',
'ayudantias', 'horas_disponibles_semanales', 'motivo_postulacion',
'proyecto_idea', 'portafolio', 'postulacion_conjunta', 'pitch', 'apodo',
];
const values = [periodId, encrypted.ciphertext, encrypted.keyVersion, ...fields.map(field => data[field])];
const result = await client.query(`
INSERT INTO public.postulaciones (
periodo_id, rut_cifrado, rut_clave_version, ${fields.join(', ')}
) VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')})
RETURNING id
`, values);
await client.query('COMMIT');
transaction = false;
return {
responseCode: 'I001',
message: 'La postulacion ha sido realizada con exito',
idPostulacion: rows[0].id as number
// bigint is returned as a string by pg, avoiding precision loss.
idPostulacion: result.rows[0].id,
};
} catch (error: unknown) {
if (client && transaction) {
try { await client.query('ROLLBACK'); } catch { discardConnection = true; }
}
} catch (err: any) {
const { status, body } = buildErrorExceptionPayload(err);
throw new HttpException(body, status, { cause: err });
if (error instanceof HttpException) throw error;
const code = (error as { code?: string })?.code;
if (code === '23505') fail(409, 'E001', 'El correo ya tiene una postulación en este período');
if (['23514', '23502', '22P02', '22001', '22003'].includes(code)) fail(400, 'E002', 'Datos inválidos');
// Do not expose pg details, request data, ciphertext, keys or exception causes.
fail(500, 'E003', 'No se pudo registrar la postulación');
} finally {
client?.release(discardConnection);
}
}

async findAll() {
const countSql = `SELECT COUNT(*)::int AS total FROM postulaciones;`;

const listSql = `
SELECT
id,
nombre_completo,
rut,
edad,
correo_institucional,
campus,
carrera,
anio_ingreso,
anio_actual,
area_interes1,
area_interes2,
area_interes3,
ayudantias,
horas_disponibles_semanales,
motivo_postulacion,
proyecto_idea,
portafolio,
postulacion_conjunta,
pitch,
apodo,
created_at,
updated_at
FROM postulaciones
ORDER BY created_at DESC;
`;

// TODO(IAM): protect this administrative listing. RUT and encryption
// metadata are deliberately excluded; never replace this with SELECT *.
try {
const [countRes, listRes] = await Promise.all([
this.pool.query(countSql),
this.pool.query(listSql),
]);

return {
totalPostulaciones: countRes.rows[0].total as number,
postulaciones: listRes.rows,
};
} catch (err: any) {
const { status, body } = buildErrorExceptionPayload(err);
throw new HttpException(body, status, { cause: err });
const { rows } = await this.pool.query(`
SELECT id, periodo_id, nombre_completo, edad, correo_institucional,
campus, carrera, anio_ingreso, anio_actual,
area_interes1, area_interes2, area_interes3, ayudantias,
horas_disponibles_semanales, motivo_postulacion, proyecto_idea,
portafolio, postulacion_conjunta, pitch, apodo,
estado_postulacion, resuelta_en, resuelta_por, created_at, updated_at
FROM public.postulaciones
ORDER BY created_at DESC, id DESC
`);
return { totalPostulaciones: rows.length, postulaciones: rows };
} catch {
fail(500, 'E003', 'No se pudieron consultar las postulaciones');
}
}

Expand Down
Loading
Loading