test(stage): пункт 11 — OrgAuditLog 7/7 ✓ + UTC fix
Some checks are pending
Some checks are pending
CRUD продукта генерирует записи create/update/delete с diff'ом полей; фильтры по entityType/entityId/action работают; multi-tenant строго (org B не видит логи org A). Bonus fix: тот же DateTime Kind=Unspecified→UTC что в reports, применён к from/to в /api/admin/audit-log. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
97d5ae5eb0
commit
6a5bb52b13
|
|
@ -27,7 +27,7 @@
|
|||
- [x] **7. CustomerReturn (Возврат от покупателя)** — UI из RetailSale, по чеку и без; возврат больше продано → 400. *(stage-customer-return.yml: 6/6 ✓)*
|
||||
- [x] **8. SupplierReturn (Возврат поставщику)** — аналог для Supply. *(stage-supplier-return.yml: 8/8 ✓)*
|
||||
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
|
||||
- [ ] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge.
|
||||
- [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)*
|
||||
- [ ] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго.
|
||||
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
|
||||
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ public record AuditRow(
|
|||
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
|
||||
if (userId is not null) q = q.Where(l => l.UserId == userId);
|
||||
if (!string.IsNullOrWhiteSpace(action)) q = q.Where(l => l.Action == action);
|
||||
if (from is not null) q = q.Where(l => l.CreatedAt >= from);
|
||||
if (to is not null) q = q.Where(l => l.CreatedAt <= to);
|
||||
// ASP.NET парсит ISO-даты с Kind=Unspecified, Npgsql отказывается слать
|
||||
// такие в timestamp with time zone — конвертим к UTC (см. ReportsController).
|
||||
if (AsUtc(from) is { } f) q = q.Where(l => l.CreatedAt >= f);
|
||||
if (AsUtc(to) is { } t) q = q.Where(l => l.CreatedAt <= t);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var items = await (from l in q.OrderByDescending(l => l.CreatedAt)
|
||||
|
|
@ -58,4 +60,12 @@ public record AuditRow(
|
|||
|
||||
return new PagedResult<AuditRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
||||
private static DateTime? AsUtc(DateTime? d) => d?.Kind switch
|
||||
{
|
||||
null => null,
|
||||
DateTimeKind.Utc => d,
|
||||
DateTimeKind.Local => d.Value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(d.Value, DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
# E2E report: stage-audit-log
|
||||
|
||||
Запущен: 2026-05-29T12:39:34.931Z
|
||||
Длительность: 11.6с
|
||||
|
||||
**Итог:** 7 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 7)
|
||||
|
||||
## ✓ Step au01_setup: 2 org, обе имеют свежий audit-log (вероятно с операциями bootstrap+signup)
|
||||
|
||||
Длительность: 8178мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | GET /api/admin/audit-log → 200 для нового tenant | ✓ 200 total=0 |
|
||||
|
||||
## ✓ Step au02_create_product_logged: Создание продукта → запись action=create с полями товара в diff
|
||||
|
||||
Длительность: 1560мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | POST product → 201 | ✓ 201 |
|
||||
| api | GET audit-log по новому product | ✓ 200 total=1 |
|
||||
| api | Есть запись action=create с EntityType=Product | ✓ action=create type=Product |
|
||||
| api | Diff содержит хотя бы поле Name | ✓ keys=Id,Vat,Cost,Name,Article |
|
||||
|
||||
## ✓ Step au03_update_product_logged: PUT продукта → запись action=update с before/after по изменённым полям
|
||||
|
||||
Длительность: 777мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | PUT product → 204 | ✓ 204 |
|
||||
| api | Есть запись action=update | ✓ action=update |
|
||||
| api | Diff содержит Name с before и after | ✓ Name before=Audit Prod 1780058374930 after=Audit Prod UPDATED 1780058374930 |
|
||||
|
||||
## ✓ Step au04_delete_product_logged: DELETE продукта → запись action=delete
|
||||
|
||||
Длительность: 706мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | DELETE product → 204 | ✓ 204 |
|
||||
| api | Есть запись action=delete | ✓ action=delete |
|
||||
|
||||
## ✓ Step au05_filter_by_entity_type: GET /audit-log?entityType=Product возвращает только Product-записи
|
||||
|
||||
Длительность: 124мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Все записи — Product | ✓ 200 count=3 allProduct=true |
|
||||
|
||||
## ✓ Step au06_filter_by_action: GET /audit-log?action=create — все create-записи
|
||||
|
||||
Длительность: 125мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Все записи — action=create | ✓ 200 count=5 allCreate=true |
|
||||
|
||||
## ✓ Step au07_multi_tenant_isolation: Org B не видит логи Org A
|
||||
|
||||
Длительность: 96мс
|
||||
|
||||
| Тип | Проверка | Результат |
|
||||
|---|---|---|
|
||||
| api | Org B не видит записи о продукте Org A | ✓ total=0 sees=false |
|
||||
|
||||
## Summary
|
||||
|
||||
- Passed: 7
|
||||
- Failed: 0
|
||||
- Warnings: 0
|
||||
- Skipped: 0
|
||||
|
||||
## Critical bugs
|
||||
|
||||
Нет.
|
||||
207
tests/e2e/scenarios/stage-audit-log.steps.ts
Normal file
207
tests/e2e/scenarios/stage-audit-log.steps.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Stage OrgAuditLog: проверка журнала мутаций через CRUD продукта.
|
||||
*/
|
||||
import { login, makeClient } from '../lib/api.js'
|
||||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||
import { generateEan13 } from '../lib/barcode.js'
|
||||
|
||||
type Org = {
|
||||
orgId: string; email: string; password: string; token: string
|
||||
storeId: string
|
||||
currencyId: string; unitKgId: string; groupId: string; priceTypeRetailId: string
|
||||
}
|
||||
type Ctx = {
|
||||
apiOnly: boolean
|
||||
ts: number
|
||||
orgA?: Org
|
||||
orgB?: Org
|
||||
productIdA?: string
|
||||
}
|
||||
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||||
|
||||
const TS = Date.now()
|
||||
function check(step: Step, c: CheckResult) { step.checks.push(c) }
|
||||
function asString(x: unknown): string {
|
||||
if (x == null) return ''
|
||||
if (typeof x === 'string') return x
|
||||
return JSON.stringify(x)
|
||||
}
|
||||
|
||||
async function bootstrapOrg(suffix: string, phone: string): Promise<Org> {
|
||||
const api = makeClient()
|
||||
const email = `stage-au-${suffix}-${TS}@food-market.local`
|
||||
const password = 'StageAu12345!'
|
||||
let r = await api.post('/api/auth/signup', { email, password, organizationName: `Au ${suffix} ${TS}`, phone, plan: 'start' })
|
||||
for (let i = 0; i < 5 && r.status === 429; i++) {
|
||||
await new Promise(res => setTimeout(res, 15000))
|
||||
r = await api.post('/api/auth/signup', { email, password, organizationName: `Au ${suffix} ${TS}`, phone, plan: 'start' })
|
||||
}
|
||||
if (r.status !== 200) throw new Error(`signup ${suffix}: ${r.status}`)
|
||||
const sess = await login(email, password)
|
||||
const auth = makeClient(sess.accessToken)
|
||||
const [stores, units, groups, prices, currencies] = await Promise.all([
|
||||
auth.get('/api/catalog/stores'),
|
||||
auth.get('/api/catalog/units-of-measure'),
|
||||
auth.get('/api/catalog/product-groups'),
|
||||
auth.get('/api/catalog/price-types'),
|
||||
auth.get('/api/catalog/currencies'),
|
||||
])
|
||||
return {
|
||||
orgId: r.data.organizationId, email, password, token: sess.accessToken,
|
||||
storeId: stores.data.items.find((s: { isMain: boolean }) => s.isMain).id,
|
||||
unitKgId: units.data.items.find((u: { code: string }) => u.code === '166').id,
|
||||
groupId: groups.data.items.find((g: { parentId: string | null }) => g.parentId == null).id,
|
||||
priceTypeRetailId: prices.data.items.find((p: { isRequired: boolean }) => p.isRequired).id,
|
||||
currencyId: currencies.data.items.find((c: { code: string }) => c.code === 'KZT').id,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au01_setup({ ctx, step, report }: StepCtx) {
|
||||
ctx.ts = TS
|
||||
ctx.orgA = await bootstrapOrg('a', '+77011190001')
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
ctx.orgB = await bootstrapOrg('b', '+77011190002')
|
||||
// Audit log endpoint должен ответить 200 на пустой
|
||||
const r = await makeClient(ctx.orgA.token).get('/api/admin/audit-log?pageSize=10')
|
||||
check(step, {
|
||||
kind: 'api', description: 'GET /api/admin/audit-log → 200 для нового tenant',
|
||||
ok: r.status === 200,
|
||||
detail: `${r.status} total=${r.data?.total}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au02_create_product_logged({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const prod = await api.post('/api/catalog/products', {
|
||||
name: `Audit Prod ${TS}`, article: `AU-${TS}`,
|
||||
unitOfMeasureId: a.unitKgId, vat: 0, vatEnabled: false, productGroupId: a.groupId,
|
||||
barcodes: [{ code: generateEan13(91), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 100, currencyId: a.currencyId }],
|
||||
})
|
||||
check(step, { kind: 'api', description: 'POST product → 201', ok: prod.status === 201, detail: `${prod.status}` })
|
||||
if (prod.status !== 201) return
|
||||
ctx.productIdA = prod.data.id
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const audit = await api.get(`/api/admin/audit-log?entityType=Product&entityId=${ctx.productIdA}&pageSize=10`)
|
||||
check(step, { kind: 'api', description: 'GET audit-log по новому product', ok: audit.status === 200, detail: `${audit.status} total=${audit.data?.total}` })
|
||||
const create = (audit.data?.items ?? []).find((x: { action: string }) => x.action === 'create')
|
||||
check(step, {
|
||||
kind: 'api', description: 'Есть запись action=create с EntityType=Product',
|
||||
ok: !!create,
|
||||
detail: create ? `action=${create.action} type=${create.entityType}` : 'не найдена',
|
||||
})
|
||||
if (create) {
|
||||
let diff: Record<string, unknown> = {}
|
||||
try { diff = JSON.parse(create.changesJson) } catch { /* ignore */ }
|
||||
check(step, {
|
||||
kind: 'api', description: 'Diff содержит хотя бы поле Name',
|
||||
ok: 'Name' in diff,
|
||||
detail: `keys=${Object.keys(diff).slice(0, 5).join(',')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au03_update_product_logged({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.productIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const upd = await api.put(`/api/catalog/products/${ctx.productIdA}`, {
|
||||
name: `Audit Prod UPDATED ${TS}`, article: `AU-${TS}`,
|
||||
unitOfMeasureId: a.unitKgId, vat: 0, vatEnabled: false, productGroupId: a.groupId,
|
||||
barcodes: [{ code: generateEan13(91), type: 1, isPrimary: true }],
|
||||
prices: [{ priceTypeId: a.priceTypeRetailId, amount: 200, currencyId: a.currencyId }],
|
||||
})
|
||||
check(step, { kind: 'api', description: 'PUT product → 204', ok: upd.status === 204, detail: `${upd.status}` })
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const audit = await api.get(`/api/admin/audit-log?entityType=Product&entityId=${ctx.productIdA}&action=update&pageSize=10`)
|
||||
const updRec = (audit.data?.items ?? []).find((x: { action: string }) => x.action === 'update')
|
||||
check(step, { kind: 'api', description: 'Есть запись action=update', ok: !!updRec, detail: updRec ? `action=${updRec.action}` : 'не найдена' })
|
||||
if (updRec) {
|
||||
let diff: Record<string, { before?: unknown; after?: unknown }> = {}
|
||||
try { diff = JSON.parse(updRec.changesJson) } catch { /* ignore */ }
|
||||
const name = diff['Name']
|
||||
check(step, {
|
||||
kind: 'api', description: 'Diff содержит Name с before и after',
|
||||
ok: name && 'before' in name && 'after' in name &&
|
||||
String(name.before).includes('Audit Prod') && String(name.after).includes('UPDATED'),
|
||||
detail: `Name before=${name?.before} after=${name?.after}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au04_delete_product_logged({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.productIdA) { step.status = 'skip'; return }
|
||||
const a = ctx.orgA
|
||||
const api = makeClient(a.token)
|
||||
const del = await api.delete(`/api/catalog/products/${ctx.productIdA}`)
|
||||
check(step, { kind: 'api', description: 'DELETE product → 204', ok: del.status === 204, detail: `${del.status}` })
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const audit = await api.get(`/api/admin/audit-log?entityType=Product&entityId=${ctx.productIdA}&action=delete&pageSize=10`)
|
||||
const delRec = (audit.data?.items ?? []).find((x: { action: string }) => x.action === 'delete')
|
||||
check(step, { kind: 'api', description: 'Есть запись action=delete', ok: !!delRec, detail: delRec ? `action=${delRec.action}` : 'не найдена' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au05_filter_by_entity_type({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||
const api = makeClient(ctx.orgA.token)
|
||||
const r = await api.get('/api/admin/audit-log?entityType=Product&pageSize=50')
|
||||
const allProduct = (r.data?.items ?? []).every((x: { entityType: string }) => x.entityType === 'Product')
|
||||
check(step, {
|
||||
kind: 'api', description: 'Все записи — Product',
|
||||
ok: r.status === 200 && allProduct && (r.data?.items?.length ?? 0) > 0,
|
||||
detail: `${r.status} count=${r.data?.items?.length} allProduct=${allProduct}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au06_filter_by_action({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA) { step.status = 'skip'; return }
|
||||
const api = makeClient(ctx.orgA.token)
|
||||
const r = await api.get('/api/admin/audit-log?action=create&pageSize=50')
|
||||
const allCreate = (r.data?.items ?? []).every((x: { action: string }) => x.action === 'create')
|
||||
check(step, {
|
||||
kind: 'api', description: 'Все записи — action=create',
|
||||
ok: r.status === 200 && allCreate,
|
||||
detail: `${r.status} count=${r.data?.items?.length} allCreate=${allCreate}`,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function au07_multi_tenant_isolation({ ctx, step, report }: StepCtx) {
|
||||
if (!ctx.orgA || !ctx.orgB) { step.status = 'skip'; return }
|
||||
const apiB = makeClient(ctx.orgB.token)
|
||||
const r = await apiB.get('/api/admin/audit-log?pageSize=50')
|
||||
const items = r.data?.items ?? []
|
||||
// Все записи должны принадлежать org B (никаких записей, у которых есть entityId
|
||||
// совпадающий с productIdA, тоже не должно быть)
|
||||
const sees = items.some((x: { entityId?: string }) => x.entityId === ctx.productIdA)
|
||||
check(step, {
|
||||
kind: 'api', description: 'Org B не видит записи о продукте Org A',
|
||||
ok: !sees,
|
||||
detail: `total=${r.data?.total} sees=${sees}`,
|
||||
})
|
||||
if (sees) {
|
||||
report.bug({
|
||||
step: 'au07', severity: 'critical',
|
||||
title: 'P0 multi-tenant утечка в /audit-log: Org B видит записи Org A',
|
||||
detail: `productId=${ctx.productIdA}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
26
tests/e2e/scenarios/stage-audit-log.yml
Normal file
26
tests/e2e/scenarios/stage-audit-log.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: stage-audit-log
|
||||
description: |
|
||||
OrgAuditLog: per-tenant журнал мутаций (через OrgAuditInterceptor).
|
||||
Создаём/обновляем/удаляем product → каждая операция должна попасть
|
||||
в /api/admin/audit-log с правильным action и diff. Multi-tenant —
|
||||
org B не видит логи org A.
|
||||
|
||||
preconditions:
|
||||
reset_db: false
|
||||
smoke_login_super_admin: false
|
||||
|
||||
steps:
|
||||
- id: au01_setup
|
||||
title: 2 org, обе имеют свежий audit-log (вероятно с операциями bootstrap+signup)
|
||||
- id: au02_create_product_logged
|
||||
title: Создание продукта → запись action=create с полями товара в diff
|
||||
- id: au03_update_product_logged
|
||||
title: PUT продукта → запись action=update с before/after по изменённым полям
|
||||
- id: au04_delete_product_logged
|
||||
title: DELETE продукта → запись action=delete
|
||||
- id: au05_filter_by_entity_type
|
||||
title: GET /audit-log?entityType=Product возвращает только Product-записи
|
||||
- id: au06_filter_by_action
|
||||
title: GET /audit-log?action=create — все create-записи
|
||||
- id: au07_multi_tenant_isolation
|
||||
title: Org B не видит логи Org A
|
||||
Loading…
Reference in a new issue