Auditoría post-incidente Dino (19–20/09/2026) — bugs, fixes por tanda, testing con SQL real, mutation testing y revisión adversarial
Incidente Dino 19–20/09/2026 (precio de autocobro 1.05 en lugar de 8490.00).
Causa raíz: el camino on-miss no aplicaba el contrato de dm_artic — tabla multi-versión por diseño
de SrvPOS, donde la fila vigente es la de mayor iVersion y, en empate, mayor iid
(NULL se trata como el valor menor). A partir de ahí se hizo una auditoría completa de bugs, se agregaron
pruebas con SQL real y mutation testing (PIT), y se aplicaron fixes en dos tandas con revisión adversarial
(judgment-day: dos jueces ciegos en paralelo, se corrige solo lo confirmado por ambos, y se vuelve a juzgar).
Ordenados por id de descubrimiento. Estado por ítem: Aplicado en rama Solo propuesto Caracterizado (sin fix) Informativo.
| ID | Bug | Severidad | Cómo se detectó | Estado |
|---|---|---|---|---|
| B1 | On-miss (findByEanSuc) sin ORDER BY → fila arbitraria entre versiones (causa del incidente). |
Alta | Logs + código | Aplicado (A1/A4) |
| B2 | Preload elegía mayor iid solo por coincidencia con el orden de inserción de SrvPOS. |
Media | Análisis fuente SrvPOS | Aplicado (A2) |
| B3 | Envase no determinista (batch sin ORDER BY + putIfAbsent). |
Media | Auditoría | Aplicado (A3) |
| B4 | NULL en columnas anulables rompía on-miss (AopInvocationException, getters primitivos de la proyección JPA) mientras el preload convertía NULL→0. |
Alta | IT sobre el DDL real | Aplicado (A4: único mapeo JDBC) |
| B5 | Refresh colgado dejaba refreshRunning=true para siempre (sin query timeout). |
Alta | Auditoría | Aplicado (A5) |
| B6 | refreshCacheBlocking sin stack trace en el log. |
Baja | Auditoría | Aplicado (A8) |
| B7 | Escritura on-miss incondicional podía pisar un valor más nuevo tras un swap. | Media | Judgment-day T1 R1 (ambos jueces) | Aplicado |
| B8 | Carrera entre dos on-miss concurrentes de la misma clave (check-then-act no atómico). | Media (real) | Judgment-day T1 R2 (ambos) | Aplicado (asMap().merge) + prueba de concurrencia real (2 hilos, falla contra el código anterior en la iteración 1) |
| B9 | WARN por cada duplicado en preload (ruido: multi-versión es por diseño). | Baja | Judgment-day T1 R1 | Aplicado (DEBUG por fila + un INFO por carga) |
| B10 | REST searchBy/findArticulos/countSearchBy: versiones superadas y duplicados, JOIN de envase multiplica filas, count ≠ filas, hasNext incorrecto, LIKE sin escape de % _ [, mismo bug NULL de B4. |
Alta | Auditoría + IT + juez B | Aplicado (A9) |
| B11 | POST /cache/refresh devolvía 202 aunque el refresh se omitiera o no arrancara. |
Media | Análisis | Aplicado (A6: 202/409/503; SrvPOS solo registra status+body) |
| B12 | Expiración fija de 4 h (con scheduler diario en Dino, todo el tráfico caía a on-miss). | Media | Análisis | Aplicado (A7) + mitigación operativa: cron cada 30 min |
| B13 | spring-websocket/spring-messaging fijados en 6.2.0 fuera del BOM. |
Media | Auditoría | Aplicado (A12 → 6.2.18) |
| B14 | iVersion BIGINT mapeado a Integer (wrap silencioso sobre 2^31−1). |
Baja/latente | IT | Aplicado (A14a: Long de punta a punta, regresión sobre 2147483648) |
| B15 | Sin @ControllerAdvice/@MessageExceptionHandler: ante falla de BD el POS recibía otro formato. |
Media | Revisión OCR | Aplicado (A14b) |
| B16 | EnvaseDto sin trim de descripción. |
Baja | Revisión OCR | Aplicado (A14c) |
| B17 | Total "Articulos" contaba filas leídas, no claves distintas. | Baja | Revisión | Aplicado (A14d) |
| B18 |
Hallado por la matriz COLLATE: cEnganche con espacios alrededor →
el JOIN SQL encuentra el envase pero la clave Java (buildEnvaseLookupKey con cEnganche
sin trim) no coincide con la del mapa (iarticulo sin padding) → el envase se descarta en silencio en
preload, on-miss y búsqueda por igual. Judgment-day tanda 2 (R1/R2) confirmó además que la clave del lado
ENVASE (iArticulo + inrosuc) también quedaba sin normalizar.
|
Media (money path, depende de datos) | IT CollationAndPaddingMatrixIT.cEngangeWithSurroundingWhitespaceSilentlyDropsTheEnvaseOnEveryPath |
Aplicado en rama — fix: trim() en ambos lados de la clave (artículo cEnganche/inrosuc y envase iArticulo/inrosuc); 4 ITs de regresión en CollationAndPaddingMatrixIT |
| B19 | Seguridad: /cache/refresh y /ws-articulos/** en permitAll; security.enabled=false en todos los perfiles; orígenes *. |
Media | Auditoría | Solo propuesto (A10) |
| B20 | Credencial de BD en application-dev.yml versionado. |
Media | Auditoría | Solo propuesto (A11, rotar) |
| B21 | Neto redondeado a decimales antes del IVA (posible 1 centavo); iTax desconocido → 21 % sin log. |
Baja/Media | Auditoría | Solo propuesto (A13, validación fiscal) |
| B22 |
Mayúsculas/minúsculas: SQL Server compara cEnganche/iArticulo sin distinguir
mayúsculas (colación CI) pero la clave Java es sensible a mayúsculas — mismo mecanismo que B18. Prueba
de caracterización CollationAndPaddingMatrixIT.cEngancheAndIarticuloDifferingOnlyByCaseCurrentlyDropTheEnvaseOnEveryPath:
con cEnganche='env9060' e iArticulo='ENV9060' el IN de SQL
(colación CI) encuentra el envase pero la clave Java (sensible a mayúsculas) no coincide → envase
omitido en preload, on-miss y búsqueda. Comportamiento actual documentado; contra datos reales no hay
casos (0 de 769).
|
Baja/teórica | Judgment-day tanda 2 (R3) + IT de caracterización | Caracterizado (sin fix) — fix propuesto: normalizar mayúsculas en ambos lados de la clave de envase |
| B23 |
Helpers de normalización duplicados con semántica distinta para valor nulo: normalize
devuelve null y normalizeCacheKeyPart devuelve "" para sucursal
nula → claves "null|x" vs "|x".
|
Info | Judgment-day tanda 2 (R3, ambos jueces) | Informativo — sin impacto real: una sucursal nula nunca entra en el IN (:sucursales) de la consulta, así que ningún envase llega al lookup. Deuda anotada: unificar ambos helpers en uno solo |
| B24 |
WITH(NOLOCK) durante la recarga nocturna: SrvPOS hace DELETE + INSERT
del bloque de la sucursal dentro de una transacción (SrvPOS_Artic_Novedades.cpp 959–1090);
un preload disparado por el cron en ese intervalo puede cargar un bloque incompleto y activarlo hasta
el siguiente refresh; un miss puede ver transitoriamente "no encontrado". Riesgo preexistente, no
introducido por la rama. Fix (A16): ninguna de las 4 consultas de
ArticulosCacheLoadRepositoryImpl usa WITH(NOLOCK) — en las dos consultas
preexistentes (lote base y envases) el hint fue eliminado; las otras dos (on-miss TOP (1)
y CTE de búsqueda) son consultas nuevas de esta rama, creadas inicialmente con el hint heredado y
corregidas en A16. Las 7 consultas JPA originales con NOLOCK de
ArticulosRepository fueron eliminadas o migradas (A4/A9). Las lecturas pasan a READ
COMMITTED (default de la BD, RCSI desactivado). Durante la recarga nocturna la
consulta espera al commit; si supera cache-query-timeout-seconds (60 s) el refresh
queda FAILED, refreshRunning se libera y la caché anterior sigue activa. Un miss en esa
ventana recibe 503 (sobre ResponseMessage) en lugar de un "no encontrado" falso. Costo:
lecturas bloqueadas unos segundos durante el completo, una vez por noche.
|
Media (ventana de carrera con la recarga de SrvPOS) | Revisión adversarial independiente (Codex), contra 860.328 filas reales | Aplicado en rama — ver A16 (READ COMMITTED, decisión del owner) |
iVersion = AUTOINC monotónico).
repository/ArticulosCacheLoadRepository(Impl).java — findArticuloForCacheMiss:
SELECT TOP (1) … WHERE dm.inrosuc = :nrosuc AND dm.cean = :ean ORDER BY dm.iVersion DESC, dm.iid DESC;
envase ORDER BY env.iVersion DESC, env.iid DESC.
repository/ArticulosRepository.java (se eliminó findByEanSuc).
service/cache/ArticuloCacheService.java (putIfHigherPriority, putOnCacheMissIfHigherPriority):
ArticuloProjection winner = activeCache.asMap().merge(cacheKey, candidate,
(existing, incoming) -> isHigherPriority(incoming, existing) ? incoming : existing);
config/CacheConfig.java (bean articuloCacheLoadJdbcTemplate con setQueryTimeout),
config/AppProperties.java, application.yml, template-application.yml.
A9 — reescritura de searchBy/findArticulos/countSearchBy con ventana:
ROW_NUMBER() OVER (PARTITION BY dm.cean, dm.inrosuc ORDER BY dm.iVersion DESC, dm.iid DESC) -> rn = 1 -> filtros -> ORDER BY iid OFFSET/FETCH
LIKE con ESCAPE '\'; count sobre el mismo conjunto filtrado; un envase por artículo.
Rendimiento medido: 80.000 filas en una sucursal → searchBy 175 ms, countSearchBy 67 ms.
controller/ArticuloCacheAdminController.java (202/409/503).CacheConfig/AppProperties (expiración configurable).pom.xml (pins de WebSocket/Messaging a 6.2.18).getLong+wasNull);
controller/GlobalRestExceptionHandler.java (DataAccessException→503, otras→500,
sobre ResponseMessage, sin SQL ni stack trace al cliente);
@MessageExceptionHandler en ArticuloWebSocketController;
EnvaseDto trim; RefreshStatusSnapshot.loadedEntries = claves distintas.
Ninguna de las 4 consultas de ArticulosCacheLoadRepositoryImpl usa WITH(NOLOCK):
en las dos consultas preexistentes (lote base y envases) el hint fue eliminado; las otras dos (on-miss
TOP (1) y CTE de búsqueda) son consultas nuevas de esta rama, creadas inicialmente con el
hint heredado y corregidas en A16. Las 7 consultas JPA originales con NOLOCK de
ArticulosRepository fueron eliminadas o migradas (A4/A9). Las lecturas pasan a READ
COMMITTED (default de la BD, RCSI desactivado). Durante la recarga nocturna la consulta espera al commit; si supera
cache-query-timeout-seconds (60 s) el refresh queda FAILED, refreshRunning
se libera y la caché anterior sigue activa. Un miss en esa ventana recibe 503 (sobre
ResponseMessage) en lugar de un "no encontrado" falso. Costo: lecturas bloqueadas unos
segundos durante el completo, una vez por noche.
Archivo: ArticulosCacheLoadRepositoryImpl.java, líneas ~58, ~76, ~104, ~154.
app.articulo.cache-query-timeout-seconds |
Default 60 s; ≤0 desactiva el timeout. |
app.articulo.cache-expire-after-write-minutes |
Default 240 min; ≤0 desactiva la expiración. |
| Métrica | Antes | Ahora |
|---|---|---|
| Pruebas unitarias | 63 | 196 |
| Integración con SQL real | 0 | 67 (14 clases) |
| PIT (mutation testing) | 166/248 (67 %) | 309/315 (98,1 %) |
| Clase | Antes | Ahora |
|---|---|---|
| ArticuloDto | 36/52 | 52/52 |
| EnvaseDto | 4/9 | 11/11 |
| ArticuloProjection | 5/8 | 6/6 |
| ArticuloProjectionRow | — | 2/2 (nuevo) |
| ArticuloService | 19/32 | 31/32 |
| ArticuloCacheService | 96/147 | 158/163 |
| GlobalRestExceptionHandler | — | 3/3 (nuevo) |
| ArticulosCacheLoadRepositoryImpl | 0/46 (sin cobertura) | 46/46 (100 %) |
Perfil pit con mutadores STRONGER. El SQL en sí no es mutable por PIT (cadenas constantes) y
sigue cubierto por las 66 pruebas con SQL real; la lógica Java de la clase (mapeo, normalización, escape de
LIKE, resolución de envases, binding de parámetros) queda al 100 %.
Cobertura nueva vía ArticulosCacheLoadRepositoryImplTest (44 pruebas, sin BD): RowMapper con
ResultSet mockeado, incluyendo coerciones NULL→0/false y iVersion >
Integer.MAX_VALUE como Long; normalize; simetría de
buildEnvaseLookupKey; escape de toLikePattern; putIfAbsent
primero-gana en findEnvasesBatchForCacheLoad; binding de parámetros capturado para
findArticuloForCacheMiss; lote base; searchBy/countSearchBy/findArticulos.
ArticuloCacheService.getCacheLoadBatchSize:312 — batchSize ≤ 0 no probado.isHigherPriority:472 — límite exacto del desempate por iid.loadSucursalInCache:366 — continuación cuando el lote == batchSize.resolvePreloadSucursalesConfig:179 (×2) — elemento null literal en la lista.ArticuloService.normalizePage:104 — límite de página.ArticuloCacheService:472 — comparison > 0, mutante >=:
comparison ∈ {−1, 0, 1} y el 0 ya fue excluido por el if anterior → equivalente;
es la línea del desempate y NO se modifica para complacer a PIT.ArticuloCacheService:366 — !lote.isEmpty() redundante con
size() == batchSize, batchSize ≥ 1 → equivalente.ArticuloCacheService:179 (×2) — ambas ramas lanzan la misma excepción → equivalente;
validación de configuración.ArticuloCacheService:312 — batchSize == null con un int primitivo
en AppProperties → inalcanzable, equivalente.ArticuloService:104 — límite de página con DEFAULT_PAGE = 0 → indistinguible.Conclusión: los 6 son equivalentes o inalcanzables; ninguno afecta precios.
Perfil Maven integration-sql (maven-failsafe, *IT.java), SQL Server local, base
descartable TsArticulosIT, DDL copiado de la estructura real de TipreRetail.dbo.dm_artic
(solo lectura de metadatos), cada sentencia destructiva verifica DB_NAME()='TsArticulosIT'.
Conexión por variables de entorno TSART_IT_DB_URL (default
jdbc:sqlserver://localhost:1433;encrypt=false;trustServerCertificate=true),
TSART_IT_DB_USER (default sa) y TSART_IT_DB_PASSWORD (sin default).
La omisión (JUnit Assumptions) depende únicamente de que TSART_IT_DB_PASSWORD no esté definida.
Las ITs viven en src/test/java y compilan siempre con mvn test-compile; Surefire
las excluye (*IT) y el perfil integration-sql habilita su ejecución con Failsafe.
OnMissDuplicateResolutionITPreloadDuplicateResolutionITCrossPathConsistencyITSucursalIsolationAndEanNormalizationITEnvaseAndTypeFidelityITNullPrimitiveProjectionCharacterizationIT (hoy regresión)SearchByPagingAndDuplicateIT (13 casos, incluye 80.000 filas)TypeFidelityMatrixIT (Long sobre Integer.MAX_VALUE, orden en el límite con NULL)MultiVersionOracleIT (211 claves, oráculo ROW_NUMBER independiente, réplica exacta del incidente)CacheLoadQueryTimeoutIT (WAITFOR DELAY contra el bean real)EnvaseCrossSucursalBatchITPreloadKeysetPaginationMatrixIT (lotes 1, 2, N-1, N, N+1, 1000; lista vs "0")CollationAndPaddingMatrixIT (10 casos: case, espacios finales, ceros a la izquierda, cEnganche '0'/''/NULL/espacios, envase con espacio final, ambos lados con relleno, caracterización B22 cEnganche/iArticulo con distinta mayúscula)ReloadTransactionIsolationIT (A16: transacción abierta con DELETE+INSERT sin commit → refresh
FAILED por timeout a 2 s, caché anterior intacta, siguiente startRefresh = STARTED, miss →
DataAccessException; tras el commit el refresh ve el estado nuevo; RED confirmado con NOLOCK
reinstalado: expected FAILED but was SUCCESS)
ArticulosCacheLoadRepositoryImplNoLockRegressionTest — verifica que ninguna constante SQL de la
clase contiene NOLOCK.
mvn -o verify
TSART_IT_DB_USER=<usuario> TSART_IT_DB_PASSWORD=<clave> mvn -o -Pintegration-sql verify
mvn -o test-compile org.pitest:pitest-maven:mutationCoverage -Ppit (reportes en target/pit-reports/)
mvnw está roto (falta .mvn/wrapper/maven-wrapper.properties): usar el Maven del sistema.
Dos jueces ciegos en paralelo revisan el mismo diff sin verse entre sí; solo se corrige lo que ambos confirman de forma independiente; luego se vuelve a juzgar sobre el resultado.
merge atómico + prueba de concurrencia.iArticulo + inrosuc)
seguía sin normalizar → corregido en findEnvasesBatchForCacheLoad y en ambos enrich….
2 ITs nuevas (envase con espacio final; ambos lados con relleno) que fallaban con el fix revertido.
normalize devuelve null y normalizeCacheKeyPart devuelve
"" para sucursal nula → claves "null|x" vs "|x"); clasificada INFO
porque una sucursal nula nunca entra en el IN (:sucursales) de la consulta, por lo que ningún
envase llega al lookup (ver B23). Queda anotada como deuda: unificar ambos helpers en uno solo.
JUDGMENT: APPROVED — tanda 1 (3 rondas) y tanda 2 (3 rondas).
Se pidió a un segundo modelo (OpenAI Codex), sin acceso a nuestras conclusiones (prompt en
prompt-codex-adversarial-review.md), una revisión adversarial de la rama. Veredicto de Codex:
REJECT (3 hallazgos). Cada hallazgo fue contrastado contra el código y contra 860.328 filas
de una copia local de TipreRetail (consultas de solo lectura).
Seis imprecisiones señaladas en este mismo reporte (diff acumulado, alcance de la búsqueda de credencial,
compilación de las ITs, gating por variables de entorno, estado de B22 y redacción de A16 sobre
WITH(NOLOCK)); todas verificadas y corregidas en esta versión.
| Hallazgo | Severidad Codex | Evidencia | Veredicto final |
|---|---|---|---|
Clave de caché ean|sucursal sensible a mayúsculas; "puede devolver la versión vieja". |
CRITICAL | EANs solo dígitos (0 filas con letras); un miss por diferencia de mayúsculas iría a la base y
devolvería la fila vigente (TOP 1 ORDER BY iVersion DESC, iid DESC); a lo sumo una
entrada duplicada en caché. |
Teórico e inofensivo; la consecuencia descrita no puede ocurrir. |
Clave de envase: cEnganche vs iArticulo con distinta capitalización
coinciden en SQL (colación CI) pero no en la clave Java. |
WARNING (real) | 769 artículos con enganche, 769 resuelven por join; diferencias de mayúsculas: 0; de espacios: 0 (cubiertas por A15). | Confirmado contra 769 casos reales (= B22, caracterizado en la sección 2); 0 de 769 tiene la variación de mayúsculas hoy. Comportamiento actual documentado; fix propuesto. |
| Miss con referencia a la caché vieja + swap → "responde el valor viejo". | WARNING (real) | La caché activa se resuelve después de leer la base; peor caso: escritura perdida en la caché que se invalida y relectura en el siguiente lookup; el valor devuelto es la fila recién leída o una cacheada con mayor (iVersion, iid), nunca más vieja. | Inofensivo; ya evaluado en judgment-day tanda 1. |
WITH(NOLOCK) durante la recarga nocturna: SrvPOS hace DELETE + INSERT
del bloque de la sucursal dentro de una transacción (SrvPOS_Artic_Novedades.cpp 959–1090); un
preload disparado por el cron en ese intervalo puede cargar un bloque incompleto y activarlo hasta el
siguiente refresh; un miss puede ver transitoriamente "no encontrado". Riesgo preexistente, no introducido
por la rama.
Fix aplicada en rama (ver A16): preload y miss con READ COMMITTED (el timeout de 60 s
mantiene la caché anterior si bloquea).
Entidad JPA Articulo con iVersion Integer (model/Articulo.java:42);
la entidad no tiene uso funcional tras A9; unificar a Long o eliminarla.
POST /cache/refresh: 409 si ya hay refresh en curso, 503 si no pudo iniciar (antes 202)./searchBy y listados devuelven menos filas donde había versiones superadas o envases duplicados; count coincide con el total real.GET /cache/refresh/status: loadedEntries pasa a ser claves distintas cargadas.ResponseMessage con 503/500 en REST y respuesta por el mismo canal en WebSocket.iVersion sigue siendo numérico en JSON.cache-query-timeout-seconds
(60 s) sea mayor que la duración típica del bloqueo o aceptar el reintento en el siguiente ciclo.app.articulo.cache-refresh-cron cada 30 minutos.