diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md index 18fe44e..fa39bcf 100644 --- a/docs/stage-testing-progress.md +++ b/docs/stage-testing-progress.md @@ -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 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 { 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), + }; } diff --git a/tests/e2e/reports/stage-audit-log-2026-05-29T12-39-46-499Z.md b/tests/e2e/reports/stage-audit-log-2026-05-29T12-39-46-499Z.md new file mode 100644 index 0000000..01fd6ae --- /dev/null +++ b/tests/e2e/reports/stage-audit-log-2026-05-29T12-39-46-499Z.md @@ -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 + +Нет. diff --git a/tests/e2e/scenarios/stage-audit-log.steps.ts b/tests/e2e/scenarios/stage-audit-log.steps.ts new file mode 100644 index 0000000..0a6b721 --- /dev/null +++ b/tests/e2e/scenarios/stage-audit-log.steps.ts @@ -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 { + 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 = {} + 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 = {} + 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}`, + }) + } +} diff --git a/tests/e2e/scenarios/stage-audit-log.yml b/tests/e2e/scenarios/stage-audit-log.yml new file mode 100644 index 0000000..7807401 --- /dev/null +++ b/tests/e2e/scenarios/stage-audit-log.yml @@ -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