Reject malformed VK imports and prevent mock live-smoke evidence
This commit is contained in:
parent
17faecd962
commit
cce70c0b8f
20 changed files with 504 additions and 19 deletions
30
agents/codex/done/2026-10-19-auditor2-high-vk-items.md
Normal file
30
agents/codex/done/2026-10-19-auditor2-high-vk-items.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Result — auditor2 HIGH malformed VK items
|
||||||
|
|
||||||
|
Base SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312`
|
||||||
|
Result SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312` (working-tree changes only; no commit)
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `src/providers/vk/vk-provider.ts`
|
||||||
|
- `tests/vk-r3-safety.test.ts`
|
||||||
|
- `agents/codex/inbox/2026-10-19-auditor2-high-vk-items.md`
|
||||||
|
- `agents/codex/notes/2026-10-19-auditor2-high-vk-items.md`
|
||||||
|
- `agents/codex/done/2026-10-19-auditor2-high-vk-items.md`
|
||||||
|
|
||||||
|
## Change and evidence
|
||||||
|
|
||||||
|
VK likes and comments payloads now fail with `VkIncompleteDataError` for missing/invalid response arrays or counts, malformed items, unsafe/noninteger/zero IDs, invalid optional profile fields, and duplicate likes/profile IDs. Negative safe-integer comment author IDs remain explicitly excluded under the documented community-author policy. Route-level regression cases verify each malformed response preserves the existing participant store state.
|
||||||
|
|
||||||
|
Commands actually run:
|
||||||
|
|
||||||
|
- `USE_VK_MOCK=true npx vitest run tests/vk-r3-safety.test.ts` — exit 0; 13 tests passed, including 25 malformed-payload route cases.
|
||||||
|
- `npx tsc --noEmit` — exit 0.
|
||||||
|
- `git diff --check -- src/providers/vk/vk-provider.ts tests/vk-r3-safety.test.ts` — exit 0.
|
||||||
|
|
||||||
|
Not run for this narrow follow-up: `npm ci`, `npx prisma generate`, full `npm test`, lint, build, or npm audit. Coordinator must rerun full gates on the integrated working tree.
|
||||||
|
|
||||||
|
CRITICAL/HIGH/MEDIUM/LOW findings: this implementation addresses the auditor2 HIGH finding; independent re-review remains required by project policy. No new issue identified by the focused checks. The auditor2 LOW COMMUNITY capability metadata finding is outside this assignment and remains for coordinator classification.
|
||||||
|
|
||||||
|
UNVERIFIED: external VK response schemas and the documented negative community author behavior have not been verified against current official VK documentation. No live VK credentials/network were used.
|
||||||
|
|
||||||
|
Remaining blockers: independent review and integrated full-gate rerun. Non-blocking tech debt: auditor2 LOW capability-metadata issue as assigned to the coordinator.
|
||||||
60
agents/codex/done/2026-10-19-auditor2.md
Normal file
60
agents/codex/done/2026-10-19-auditor2.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Independent auditor2 review — stage1 R1–R5
|
||||||
|
|
||||||
|
Reviewed SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312`
|
||||||
|
Base SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`
|
||||||
|
Result SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312` (review only; no code changes).
|
||||||
|
|
||||||
|
## Scope and conclusion
|
||||||
|
|
||||||
|
Reviewed the immutable base-to-HEAD patch against R1–R5, with emphasis on malformed/incomplete VK responses and persistence, unsupported repost/manager checks, smoke behavior and credential handling, migration bootstrap, production socket trust, deployment binding and operator runbook.
|
||||||
|
|
||||||
|
One **HIGH** issue remains in R3: likes/comments item records are not validated, allowing malformed VK payload elements to be silently accepted or omitted and the resulting list to be persisted as complete. One **LOW** issue remains in capability metadata: COMMUNITY auth is reported as enabling admin detection despite no manager-list fetch implementation. No CRITICAL findings. This is not a phase-closure decision.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### HIGH — malformed likes/comments can produce a persisted “complete” participant list
|
||||||
|
|
||||||
|
Evidence: [vk-provider.ts](/srv/projects/Agent%20projects/randomayzer/src/providers/vk/vk-provider.ts:272) converts `item.id` directly with `String(item.id)` and inserts the participant without checking that the ID is a valid user identifier. The comments path accepts any `from_id` satisfying JavaScript `> 0` and silently ignores other values at [vk-provider.ts](/srv/projects/Agent%20projects/randomayzer/src/providers/vk/vk-provider.ts:308). The pagination guard at [vk-client.ts](/srv/projects/Agent%20projects/randomayzer/src/integrations/vk/vk-client.ts:249) validates array size and reported totals, but not element shape. The route then saves whatever the provider/pipeline returned at [participants/route.ts](/srv/projects/Agent%20projects/randomayzer/src/app/api/giveaways/%5Bid%5D/participants/route.ts:95).
|
||||||
|
|
||||||
|
For example, a likes page with a reported matching count and an item missing `id` becomes participant ID `"undefined"`; multiple such records collapse in the map. A malformed comment `from_id` is counted by pagination but omitted from participants. Either path can silently change draw eligibility while completing the import and replacing the previous list. The added R3 tests cover missing/short pages and incomplete `groups.isMember` results, but do not exercise malformed likes/comments item payloads.
|
||||||
|
|
||||||
|
The provider should reject malformed participant records with the explicit incomplete-data error before returning; a regression test should assert the existing participant list survives each malformed payload case.
|
||||||
|
|
||||||
|
### LOW — COMMUNITY capability metadata claims admin detection without an implementation
|
||||||
|
|
||||||
|
Evidence: [vk-capabilities.ts](/srv/projects/Agent%20projects/randomayzer/src/providers/vk/vk-capabilities.ts:34) sets `adminDetection: true` whenever the auth type is `COMMUNITY` and clears the explanation. The provider implementation has no manager-list lookup, and [participant-enricher.ts](/srv/projects/Agent%20projects/randomayzer/src/core/pipeline/participant-enricher.ts:28) only enriches subscriptions. Actual participant/snapshot validation currently uses the provider’s static capabilities (`adminDetection: false`), so this metadata discrepancy does not bypass the current server guard; however, the preview API can return a capability consumers could treat as available.
|
||||||
|
|
||||||
|
Keep this capability false until a complete manager-detection implementation exists, or derive it from a separately verified capability; add a test for COMMUNITY auth metadata. Current end-user flow selecting manager exclusion is disabled with a clear explanation, and the server validator rejects it.
|
||||||
|
|
||||||
|
## Areas reviewed without additional findings
|
||||||
|
|
||||||
|
- Pagination rejects invalid/changing totals, short/empty pages before total, oversized pages, missing totals for incomplete data, and max-page truncation. Subscription enrichment validates response shape, unexpected/duplicate/missing IDs and member values. The import route writes only after all fetch/enrichment succeeds.
|
||||||
|
- Reposts and manager exclusion are rejected by provider capability validation with reasons; the creation UI visibly disables both unsupported options and explains why. The code makes ordinary paginated calls and does not use `execute`.
|
||||||
|
- Smoke configuration requires a non-symlink regular mode-0600 file, allowlists keys and URL origins, keeps cookie/basic-auth material in curl config under a private temp directory, does not print response bodies, and reports `SKIP`/`MANUAL` distinctly. Exit status is zero when executed checks pass even when others are skipped/manual, but the script prints skip counts and its documentation explicitly says skipped checks are not passed; I did not classify that as a false PASS.
|
||||||
|
- The new initial migration intentionally models the pre-ownership schema; the following ownership migration aborts on null owner rows, then makes the column non-null and changes FK behavior. The runbook explicitly requires backup/schema/history inspection and says not to apply CREATE TABLE over existing tables. The recorded disposable-PostgreSQL migration evidence includes a zero-difference Prisma schema check.
|
||||||
|
- The production entrypoint replaces proxy metadata before Next receives the request, derives trust from `socket.remoteAddress`, strips proxy headers from untrusted peers, and allows only the configured `10.77.0.1` peer in the domain profile. Tests cover forged headers from several peers and a real loopback socket. Compose publishes only loopback plus the explicit WireGuard address in the domain overlay; DB has no host port. The runbook gates the actual VPS/WireGuard source identity and firewall persistence on operator checks.
|
||||||
|
- The deployment runbook includes startup, migration, backup/restore, update/rollback and two-host domain procedures. It requires the correct existing proxy and firewall manager and avoids changing the tunnel or unrelated services.
|
||||||
|
|
||||||
|
## Commands run by this review
|
||||||
|
|
||||||
|
- `git status --short --branch` — branch `codex/stage1-completion`; pre-existing `M AGENTS.md` preserved.
|
||||||
|
- `git rev-parse HEAD` — `17faecd962c2cf40e0a3f6ec4434811cfffdd312`.
|
||||||
|
- `USE_VK_MOCK=true npx vitest run tests/vk-r3-safety.test.ts tests/production-transport.test.ts tests/deployment-profile.test.ts` — exit 0; 3 files, 26 tests passed.
|
||||||
|
- `git diff --check 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60..17faecd962c2cf40e0a3f6ec4434811cfffdd312` — exit 0, no whitespace errors.
|
||||||
|
|
||||||
|
Not run by this reviewer: `npm ci`, Prisma generation, full unit/integration suites, lint, build, dependency audit, Docker stack rebuild, live VK smoke, live VPS/domain/network acceptance. Coordinator owns the full gates and Docker work.
|
||||||
|
|
||||||
|
## UNVERIFIED and remaining blockers
|
||||||
|
|
||||||
|
- Actual live VK response schemas and official behavior for manager listing, private-profile reposts, pagination consistency and method limits remain unverified. This review makes no claim about external VK contracts.
|
||||||
|
- Whether the deployed Docker/VPS path preserves the trusted socket peer as `10.77.0.1`, and whether firewall rules persist correctly, still require operator verification on the target hosts.
|
||||||
|
- Live VK consent, domain login, TLS/basic-auth behavior, and external/LAN/WireGuard isolation remain unverified.
|
||||||
|
- R3 malformed participant item validation is the release-blocking code issue identified here; regression coverage and repair remain outstanding.
|
||||||
|
|
||||||
|
## Files changed by this review session
|
||||||
|
|
||||||
|
- `agents/codex/inbox/2026-10-19-auditor2.md` — assignment record.
|
||||||
|
- `agents/codex/done/2026-10-19-auditor2.md` — this report.
|
||||||
|
- `/srv/projects/AI-Memory/01_PROJECTS/randomayzer/worklog/2026-10-19_auditor2_stage1-review.md` — individual memory worklog.
|
||||||
|
|
||||||
|
No production code or tests were changed; no commit was created. The pre-existing `AGENTS.md` modification was left untouched.
|
||||||
14
agents/codex/done/2026-10-19-smoke-mock-fix.md
Normal file
14
agents/codex/done/2026-10-19-smoke-mock-fix.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Audit-loop implementation — smoke mock false positive
|
||||||
|
Base/Reviewed SHA: 17faecd962c2cf40e0a3f6ec4434811cfffdd312
|
||||||
|
HEAD at report generation: 17faecd962c2cf40e0a3f6ec4434811cfffdd312; follow-up changes uncommitted pending shared fix commit.
|
||||||
|
|
||||||
|
Independent auditor1 MEDIUM is addressed by production factory refusal of explicit VK mocks, Compose USE_VK_MOCK=false, and server-generated actual providerMode in preview/import summary. Smoke requires LIVE mode and a nonempty import for G/H; zero participants cannot prove subscription API execution. All prior existing tests remain untouched; new tests exercise mock-preview labeling, production refusal, live-instance labeling, actual Bash smoke against MOCK/LIVE/zero-count HTTP fixtures and secret-marker suppression.
|
||||||
|
|
||||||
|
Changed files: src/providers/factory.ts; src/app/api/posts/preview/route.ts; src/app/api/giveaways/[id]/participants/route.ts; compose.production.yml; tools/vk-smoke.sh; docs/VK_REAL_SMOKE_TEST.md; docs/PRODUCTION_GUARDS.md; tests/vk-smoke-provider-mode.test.ts; this inbox/result.
|
||||||
|
|
||||||
|
Commands actually run:
|
||||||
|
- npx vitest run tests/vk-smoke-provider-mode.test.ts: exit0, 6 tests passed (including actual shell subprocess regressions).
|
||||||
|
- bash -n tools/vk-smoke.sh: exit0.
|
||||||
|
- git diff --check: exit0.
|
||||||
|
|
||||||
|
Full gate and independent rereview pending at fix SHA. No real VK credentials, no live VK evidence. Finding remains open until independent rereview. Other reviewer HIGH malformed-items fix is separate provider/test work by coder1, to be included in same fix commit. Pre-existing COMMUNITY admin metadata LOW remains nonblocking debt; server static capability guard rejects unsupported manager exclusion.
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Result — auditor1 independent review
|
||||||
|
|
||||||
|
Base SHA: `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`
|
||||||
|
Reviewed SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312`
|
||||||
|
Result SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312` (review only; no code change)
|
||||||
|
Working-tree context: pre-existing `AGENTS.md` modification was preserved and excluded from the reviewed commit range.
|
||||||
|
|
||||||
|
Scope: R1–R5 acceptance review with focus on production auth profile and CSRF/Secure cookie strictness, socket-derived proxy trust, deployment networks/backups, R3 fail-closed/no-partial-save behavior, and smoke-report truthfulness.
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
### MEDIUM — Live VK smoke can report mock-provider results as real VK checks
|
||||||
|
|
||||||
|
Files/lines: [`tools/vk-smoke.sh`](/srv/projects/Agent%20projects/randomayzer/tools/vk-smoke.sh:105), [`src/providers/factory.ts`](/srv/projects/Agent%20projects/randomayzer/src/providers/factory.ts:8), [`src/providers/vk/vk-mock-provider.ts`](/srv/projects/Agent%20projects/randomayzer/src/providers/vk/vk-mock-provider.ts:113).
|
||||||
|
|
||||||
|
`ProviderFactory.getVkProvider()` selects `VkMockProvider` whenever `USE_VK_MOCK=true`, before it checks for `VK_SERVICE_TOKEN`. The production Compose service inherits arbitrary values from its configured `env_file` and does not reject or override this flag. The smoke script never confirms which provider answered: D accepts a mock `resolvedAuthType: SERVICE` as `PUBLIC_SERVICE`; F/G/H can create a database record, import generated participants, and receive generated subscription statuses. E may be omitted (`VK_SMOKE_PRIVATE_POST_URL` is optional). The script exits successfully whenever there are no `FAIL` lines; `SKIP` does not make the run fail (lines 176–177).
|
||||||
|
|
||||||
|
Reproducer: configure the production env file with `USE_VK_MOCK=true` alongside the normal credentials, complete the real browser consent for A–C, set a public post URL and `VK_SMOKE_CREATE_GIVEAWAY=YES`, and omit `VK_SMOKE_PRIVATE_POST_URL`. The application serves the mock provider despite a service token; A–C can pass from the actual auth flow, D and F/G/H can report `PASS` from generated data, E reports `SKIP`, and the script exits 0. This does not demonstrate public post retrieval, participant import, or subscription checks against VK. It makes the smoke result insufficient evidence for those live checks.
|
||||||
|
|
||||||
|
## Other severity levels
|
||||||
|
|
||||||
|
CRITICAL: none found in the reviewed scope.
|
||||||
|
|
||||||
|
HIGH: none found in the reviewed scope.
|
||||||
|
|
||||||
|
LOW: none found in the reviewed scope.
|
||||||
|
|
||||||
|
## Review notes
|
||||||
|
|
||||||
|
- Production auth configuration uses canonical `APP_BASE_URL`, permits HTTP only when `DEPLOYMENT_PROFILE=local-single-user` and the origin is loopback, rejects local-profile HTTPS/LAN/public origins, requires matching callback origin/path, and makes the session cookie Secure in strict production. CSRF uses configured origin in production, checks scheme as well as host, ignores request Host and forwarded Host, and requires Origin/Referer for production mutations.
|
||||||
|
- The supplied production entry point removes client proxy identity headers and derives trust from `req.socket.remoteAddress`; only `10.77.0.1` may supply a single valid forwarded IP and HTTPS scheme. It overwrites Host/forwarded host from the canonical origin. The Compose files map the app to loopback and (in the domain overlay) the WG address, leave PostgreSQL unpublished, and declare dedicated named resources and CPU/memory limits. Actual Docker/NAT socket peer behavior and host firewall rules remain operator-side acceptance checks.
|
||||||
|
- R3 pagination checks totals, malformed/oversized/short pages and page ceilings. Subscription enrichment rejects absent, duplicate, unexpected, or malformed statuses. The participant import route only calls the atomic store update after fetch and enrichment complete, and its focused test proves an incomplete subscription response preserves prior participants. Unsupported repost/admin rules fail with explanatory capability errors. The code uses ordinary VK methods and does not call VK `execute`.
|
||||||
|
- Backup commands use a 0600 env-file check and restrictive umask; the daily workflow validates archive readability, restores to a new temporary database with `--exit-on-error`, and applies retention only after the restore check succeeds. Actual backup/restore and daily timer execution are not evidenced by this review.
|
||||||
|
- The migration bootstrap was inspected for scope and the runbook guards existing databases against applying the initial CREATE schema over existing tables. Root owns the clean PostgreSQL migration/integration gate.
|
||||||
|
|
||||||
|
## Commands actually run
|
||||||
|
|
||||||
|
- `git status --short` — showed only pre-existing ` M AGENTS.md` before this review.
|
||||||
|
- `git rev-parse HEAD` and `git rev-parse <base>^{commit}` / `git rev-parse <reviewed>^{commit}` — HEAD and reviewed SHA are `17faecd962c2cf40e0a3f6ec4434811cfffdd312`; base resolved to `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60`.
|
||||||
|
- `git diff --stat` and `git diff --name-status <base>..<reviewed>` — inspected the full 51-file change set.
|
||||||
|
- `npx vitest run tests/deployment-profile.test.ts tests/production-transport.test.ts tests/vk-r3-safety.test.ts` — exit 0; 3 files and 26 tests passed.
|
||||||
|
- `bash -n tools/vk-smoke.sh tools/db-backup.sh tools/db-backup-daily.sh tools/db-restore-check.sh` — exit 0.
|
||||||
|
- `node --check tools/production-transport.cjs` — exit 0.
|
||||||
|
- `node --check tools/serve-production.cjs` — exit 0.
|
||||||
|
- `git diff --check <base>..<reviewed>` — exit 0.
|
||||||
|
- Full `npm ci`, Prisma generate, full tests, lint, build, npm audit, real PostgreSQL migration/integration and Docker stack/backup-recovery gates were not run by this auditor; root owns them.
|
||||||
|
|
||||||
|
## UNVERIFIED
|
||||||
|
|
||||||
|
- Real VK ID browser consent and the actual support of the loopback redirect URI.
|
||||||
|
- Live VK behavior for public/restricted posts, pagination, subscription responses and scopes; the smoke false-positive finding above must be addressed before D/F/G/H output can serve as live evidence.
|
||||||
|
- R5 DNS, VPS identity, existing proxy server/config, certificate issuance, Basic Auth 401, live OAuth over the domain, Docker-preserved socket peer address, host firewall persistence, and internet/LAN/WG isolation.
|
||||||
|
- Actual daily backup, restore drill, retention, service timer and production rollback.
|
||||||
|
- Full R1 audit and R2 clean real-PostgreSQL acceptance are outside this review's executed checks.
|
||||||
|
|
||||||
|
## Remaining blockers and non-blocking tech debt
|
||||||
|
|
||||||
|
- Blocker for treating R3 smoke D/F/G/H as live acceptance: prevent `USE_VK_MOCK=true` in production (or make the smoke prove that real-provider mode is active) and rerun against VK. Keep the result for this finding open until the false-positive path is removed and independently rechecked.
|
||||||
|
- R5 and R4 operator acceptance remain pending per the runbook; this review does not mark any phase closed.
|
||||||
|
- No additional non-blocking tech debt identified in the reviewed acceptance scope.
|
||||||
|
|
||||||
|
Final verdict: one MEDIUM finding; do not treat live VK smoke checks as accepted evidence until fixed and rerun. No CRITICAL/HIGH finding was established by this review.
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Task
|
||||||
|
Base SHA: 6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60
|
||||||
|
Reviewed SHA: 17faecd962c2cf40e0a3f6ec4434811cfffdd312
|
||||||
|
Scope: Independent read-only audit of R1–R5 acceptance in the specified immutable base..HEAD range. Focus on auth profile/CSRF/Secure strictness, socket-origin and trusted-proxy behavior, deploy network and backup safety, R3 fail-closed/no partial saves, and live smoke truthfulness.
|
||||||
|
Do not change: Production code/tests, containers, or anything outside this assignment/report and individual memory worklog. No commit/push/delegation. Preserve pre-existing AGENTS.md modification.
|
||||||
|
Definition of Done: Findings with severity, file/line, impact and reproducible scenario; reviewed SHA; commands and actual results; UNVERIFIED claims and blockers.
|
||||||
|
Required verification: Static source review and focused, non-conflicting checks only; root owns npm ci/generate/full gates/PostgreSQL/Docker.
|
||||||
|
Authorization evidence: agents/codex/inbox/2026-10-19-stage1-completion.md and agents/codex/inbox/2026-10-19-seed-owner-authorization.md.
|
||||||
5
agents/codex/inbox/2026-10-19-auditor2-high-vk-items.md
Normal file
5
agents/codex/inbox/2026-10-19-auditor2-high-vk-items.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Assignment — auditor2 HIGH: malformed VK likes/comments
|
||||||
|
|
||||||
|
Base SHA: `17faecd962c2cf40e0a3f6ec4434811cfffdd312`
|
||||||
|
|
||||||
|
Fix only `src/providers/vk/vk-provider.ts` and `tests/vk-r3-safety.test.ts` for the auditor2 HIGH finding in `agents/codex/done/2026-10-19-auditor2.md`. Reject malformed likes/comment records, malformed optional comment profile data, and duplicate likes IDs with `VkIncompleteDataError` before participant persistence. Valid positive safe-integer user IDs are accepted. Valid negative integer comment authors may be excluded under an explicit documented community-author policy. Add route-level regression coverage showing old stored participants survive malformed likes/comments responses. No existing tests edits, no commit, no auth/provider capability changes, and no edits to unrelated files.
|
||||||
3
agents/codex/inbox/2026-10-19-auditor2.md
Normal file
3
agents/codex/inbox/2026-10-19-auditor2.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Auditor2 assignment — stage1 completion
|
||||||
|
|
||||||
|
Independent read-only adversarial/code review of `6382cb8e0473ba9ea5ba7a3f94f34e8ab0d4ec60..17faecd962c2cf40e0a3f6ec4434811cfffdd312` against R1–R5 acceptance. Scope: incomplete/malformed VK data and partial persistence; unsupported managers/reposts/execute safeguards and participant/UI reasons; smoke false PASS cases and credentials; initial migration legacy safety; production socket trust versus spoofed headers; loopback/domain binding and executable operator runbook. Read AGENTS.md, stage1 assignment, seed owner authorization, agents/README, and required project memory. No production/test edits, commit/push, container mutation, or auditor1 communication. Report severity, file/line/evidence, SHA, commands, UNVERIFIED blockers/debt. Root owns full gates.
|
||||||
5
agents/codex/inbox/2026-10-19-smoke-mock-fix.md
Normal file
5
agents/codex/inbox/2026-10-19-smoke-mock-fix.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Audit-loop fix — prevent mock data as live VK evidence
|
||||||
|
Base/reviewed SHA: 17faecd962c2cf40e0a3f6ec4434811cfffdd312
|
||||||
|
Original task authorizes coder/audit repair loop. Independent auditor1 MEDIUM: explicit USE_VK_MOCK=true can make live smoke D/F/G/H report generated data as real VK.
|
||||||
|
Scope: forbid mock-provider selection in production; expose nonsecret provider mode in preview/import response; require LIVE mode in smoke and nonempty import before reporting subscription check; add exact reproducer regression in new test file. Preserve all existing tests except previously authorized seed assertions. No credentials/production host changes.
|
||||||
|
Definition of Done: mock PoC fails safely; live-mode fixture passes automated checks, B remains MANUAL; full gates and independent rereview.
|
||||||
14
agents/codex/notes/2026-10-19-auditor2-high-vk-items.md
Normal file
14
agents/codex/notes/2026-10-19-auditor2-high-vk-items.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Notes — auditor2 HIGH fix (VK participant records)
|
||||||
|
|
||||||
|
Base: `17faecd962c2cf40e0a3f6ec4434811cfffdd312`. No commit created; HEAD remains the base SHA.
|
||||||
|
|
||||||
|
Implementation validates VK response object/count/items before pagination accepts a page. Likes must be profile objects with positive safe-integer IDs and valid optional profile field types; duplicate likes IDs across pages fail. Comment authors must be safe integers and nonzero. Negative safe integers are explicitly treated as community authors and excluded; malformed/zero authors fail. Optional comment profiles, when provided, must be an array of valid positive-ID profiles with no duplicate IDs. All validation failures throw `VkIncompleteDataError` before the provider returns to the import route.
|
||||||
|
|
||||||
|
The route regression table covers 25 malformed response variants (likes and comments, including missing/null items/arrays/counts, invalid IDs, duplicate likes/profile IDs, and invalid profile properties). Every case asserts HTTP `VK_INCOMPLETE_DATA` and that the previously stored participant list remains byte-for-byte equal at the repository result layer. A separate case confirms an otherwise valid negative community author remains intentionally excluded.
|
||||||
|
|
||||||
|
Verification run:
|
||||||
|
- `USE_VK_MOCK=true npx vitest run tests/vk-r3-safety.test.ts` — exit 0, 1 file / 13 tests passed; the route table exercises 25 response cases.
|
||||||
|
- `npx tsc --noEmit` — exit 0.
|
||||||
|
- `git diff --check -- src/providers/vk/vk-provider.ts tests/vk-r3-safety.test.ts` — exit 0.
|
||||||
|
|
||||||
|
No `npm ci`, prisma generation, full test, lint or build was run for this narrow follow-up. Those gates need to be repeated by the coordinator after the complete shared patch is stable.
|
||||||
|
|
@ -31,3 +31,9 @@ R4 evidence before coder2:
|
||||||
Gate caveat:
|
Gate caveat:
|
||||||
- One raw npm test run timed out in existing tests/security.test.ts unmocked live-network preview case (368/369); a focused unchanged rerun passed7/7, network case725ms.
|
- One raw npm test run timed out in existing tests/security.test.ts unmocked live-network preview case (368/369); a focused unchanged rerun passed7/7, network case725ms.
|
||||||
- USE_VK_MOCK=true full suite later passed372/372. No test timeout or assertions were weakened; final raw run pending.
|
- USE_VK_MOCK=true full suite later passed372/372. No test timeout or assertions were weakened; final raw run pending.
|
||||||
|
|
||||||
|
Independent review cycle 1 (immutable 17faecd962c2cf40e0a3f6ec4434811cfffdd312):
|
||||||
|
- auditor1 MEDIUM: smoke may accept generated mock VK results as live evidence; coordinator fixing production refusal + explicit actual-provider mode and subprocess regressions.
|
||||||
|
- auditor2 HIGH: malformed likes/comments accepted or silently omitted before complete save; coder1 assigned strict element validation + route preservation PoC regressions.
|
||||||
|
- auditor2 LOW: COMMUNITY effective adminDetection metadata remains true although no manager implementation. This predates our patch; actual static server validation disables manager exclusion, route currently uses SERVICE/USER. Classify as non-blocking pre-existing debt, do not change old tests/expand scope silently.
|
||||||
|
- Final gates at 17faecd: npm ci/generate/test372/372/tsc/lint(0errors6warnings)/build/audit0 all exit0. Fresh DB randomayzer_stage1_final migration3 applied, integration12/12, schema diff0. Follow-up fixes require new gates/rereview; prior passes do not close findings.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
STORAGE_DRIVER: prisma
|
STORAGE_DRIVER: prisma
|
||||||
|
USE_VK_MOCK: "false"
|
||||||
DATABASE_URL: postgresql://randomayzer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/randomayzer
|
DATABASE_URL: postgresql://randomayzer:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/randomayzer
|
||||||
DEPLOYMENT_PROFILE: local-single-user
|
DEPLOYMENT_PROFILE: local-single-user
|
||||||
APP_BASE_URL: http://localhost:3100
|
APP_BASE_URL: http://localhost:3100
|
||||||
|
|
|
||||||
|
|
@ -117,5 +117,5 @@ If the requested winners count plus reserve exceeds the locked snapshot's eligib
|
||||||
| `TRUST_PROXY` | boolean (`true`/`false`) | `false` | Enable only behind trusted upstream proxies |
|
| `TRUST_PROXY` | boolean (`true`/`false`) | `false` | Enable only behind trusted upstream proxies |
|
||||||
| `ALLOW_MEMORY_IDEMPOTENCY` | boolean | `false` | Silence production warning for memory idempotency in single-instance |
|
| `ALLOW_MEMORY_IDEMPOTENCY` | boolean | `false` | Silence production warning for memory idempotency in single-instance |
|
||||||
| `ALLOW_MEMORY_RATE_LIMITER` | boolean | `false` | Silence production warning for memory rate limiter in single-instance |
|
| `ALLOW_MEMORY_RATE_LIMITER` | boolean | `false` | Silence production warning for memory rate limiter in single-instance |
|
||||||
| `USE_VK_MOCK` | boolean | `false` | Explicitly enable VkMockProvider (forbidden in production unless true) |
|
| `USE_VK_MOCK` | boolean | `false` | Explicitly enable VkMockProvider in development/test; always forbidden in production |
|
||||||
| `VK_SERVICE_TOKEN` | string | `undefined` | VK Application Service Access Token |
|
| `VK_SERVICE_TOKEN` | string | `undefined` | VK Application Service Access Token |
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ The smoke tool checks the configured application and VK account without placing
|
||||||
./tools/vk-smoke.sh ~/.vk-smoke.env
|
./tools/vk-smoke.sh ~/.vk-smoke.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The server reports the actual provider mode; previews/imports must report `LIVE` for these checks to pass. Production refuses `USE_VK_MOCK=true`, and the production Compose profile explicitly disables it. G/H require nonempty participants: an empty import cannot prove that subscription calls ran.
|
||||||
|
|
||||||
The script prints `PASS`, `FAIL`, `SKIP`, or `MANUAL` for checks A–H and exits nonzero when any executed check fails. It keeps response bodies and HTTP headers in a private temporary directory and removes that directory on exit. It does not print profile or participant data. A successful run of F creates a real giveaway record; G imports participants and H reimports them with the subscription condition. Choose a known test post and community.
|
The script prints `PASS`, `FAIL`, `SKIP`, or `MANUAL` for checks A–H and exits nonzero when any executed check fails. It keeps response bodies and HTTP headers in a private temporary directory and removes that directory on exit. It does not print profile or participant data. A successful run of F creates a real giveaway record; G imports participants and H reimports them with the subscription condition. Choose a known test post and community.
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { GiveawayStore } from '@/lib/giveaway-store';
|
import { GiveawayStore } from '@/lib/giveaway-store';
|
||||||
import { ProviderFactory } from '@/providers/factory';
|
import { ProviderFactory } from '@/providers/factory';
|
||||||
|
import { VkProvider } from '@/providers/vk/vk-provider';
|
||||||
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher';
|
||||||
import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
|
import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError } from '@/core/errors/http-errors';
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
|
|
@ -99,6 +100,7 @@ export async function POST(
|
||||||
const responseBody = {
|
const responseBody = {
|
||||||
success: true,
|
success: true,
|
||||||
giveawayId: updated.id,
|
giveawayId: updated.id,
|
||||||
|
providerMode: provider instanceof VkProvider ? 'LIVE' : 'MOCK',
|
||||||
totalCount: allParticipants.length,
|
totalCount: allParticipants.length,
|
||||||
eligibleCount: eligibleParticipants.length,
|
eligibleCount: eligibleParticipants.length,
|
||||||
excludedCount: excludedParticipants.length,
|
excludedCount: excludedParticipants.length,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { ProviderFactory } from '@/providers/factory';
|
import { ProviderFactory } from '@/providers/factory';
|
||||||
|
import { VkProvider } from '@/providers/vk/vk-provider';
|
||||||
import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
import { postPreviewSchema } from '@/core/validation/giveaway-schemas';
|
||||||
import { handleApiError } from '@/core/errors/http-errors';
|
import { handleApiError } from '@/core/errors/http-errors';
|
||||||
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
import { generalApiRateLimiter } from '@/lib/rate-limiter';
|
||||||
|
|
@ -31,6 +32,7 @@ export async function POST(req: NextRequest) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
post,
|
post,
|
||||||
|
providerMode: provider instanceof VkProvider ? 'LIVE' : 'MOCK',
|
||||||
effectiveCapabilities,
|
effectiveCapabilities,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export class ProviderFactory {
|
||||||
public static getVkProvider(): SocialMediaProvider {
|
public static getVkProvider(): SocialMediaProvider {
|
||||||
// 1. Explicit mock configuration
|
// 1. Explicit mock configuration
|
||||||
if (process.env.USE_VK_MOCK === 'true') {
|
if (process.env.USE_VK_MOCK === 'true') {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new DependencyUnavailableError('VK mock provider is forbidden in production. Remove USE_VK_MOCK.');
|
||||||
|
}
|
||||||
return new VkMockProvider();
|
return new VkMockProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,42 @@ import {
|
||||||
import { STATIC_VK_CAPABILITIES } from './vk-capabilities';
|
import { STATIC_VK_CAPABILITIES } from './vk-capabilities';
|
||||||
import { VkAuthContextResolver, defaultVkAuthContextResolver } from '@/integrations/vk/vk-auth-resolver';
|
import { VkAuthContextResolver, defaultVkAuthContextResolver } from '@/integrations/vk/vk-auth-resolver';
|
||||||
|
|
||||||
|
function incompleteVkItems(method: string, reason: string): VkIncompleteDataError {
|
||||||
|
return new VkIncompleteDataError(`VK ${method} returned incomplete participant data: ${reason}. No result was saved.`, { method });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProfile(value: unknown, method: string): VkUserProfile {
|
||||||
|
if (!isRecord(value) || !Number.isSafeInteger(value.id) || (value.id as number) <= 0) {
|
||||||
|
throw incompleteVkItems(method, 'profile has an invalid user ID');
|
||||||
|
}
|
||||||
|
for (const key of ['first_name', 'last_name', 'screen_name', 'photo_50', 'photo_100', 'photo_200', 'deactivated']) {
|
||||||
|
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||||
|
throw incompleteVkItems(method, `profile has an invalid ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.is_closed !== undefined && typeof value.is_closed !== 'boolean') {
|
||||||
|
throw incompleteVkItems(method, 'profile has an invalid is_closed value');
|
||||||
|
}
|
||||||
|
return value as unknown as VkUserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResponseItems(value: unknown, method: string): Record<string, unknown>[] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.items)) {
|
||||||
|
throw incompleteVkItems(method, 'response items are missing or invalid');
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(value.count) || (value.count as number) < 0) {
|
||||||
|
throw incompleteVkItems(method, 'response count is missing or invalid');
|
||||||
|
}
|
||||||
|
for (const item of value.items) {
|
||||||
|
if (!isRecord(item)) throw incompleteVkItems(method, 'response contains a malformed item');
|
||||||
|
}
|
||||||
|
return value.items as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExtendedFetchParticipantsParams extends FetchParticipantsParams {
|
export interface ExtendedFetchParticipantsParams extends FetchParticipantsParams {
|
||||||
authContext?: VkAuthContext;
|
authContext?: VkAuthContext;
|
||||||
organizerId?: string;
|
organizerId?: string;
|
||||||
|
|
@ -244,6 +280,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
|
|
||||||
// 1. Fetch Likes with full pagination
|
// 1. Fetch Likes with full pagination
|
||||||
if (params.includeLikes !== false) {
|
if (params.includeLikes !== false) {
|
||||||
|
const seenLikeIds = new Set<number>();
|
||||||
const likeProfiles = await fetchPaginatedVk<VkUserProfile>({
|
const likeProfiles = await fetchPaginatedVk<VkUserProfile>({
|
||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
fetchPage: async (offset, count) => {
|
fetchPage: async (offset, count) => {
|
||||||
|
|
@ -257,10 +294,13 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
offset,
|
offset,
|
||||||
}, authContext);
|
}, authContext);
|
||||||
|
|
||||||
return {
|
const items = validateResponseItems(res, 'likes.getList');
|
||||||
items: (res.items || []) as VkUserProfile[],
|
const profiles = items.map(item => validateProfile(item, 'likes.getList'));
|
||||||
totalCount: res.count,
|
for (const profile of profiles) {
|
||||||
};
|
if (seenLikeIds.has(profile.id)) throw incompleteVkItems('likes.getList', 'duplicate user ID');
|
||||||
|
seenLikeIds.add(profile.id);
|
||||||
|
}
|
||||||
|
return { items: profiles, totalCount: res.count };
|
||||||
},
|
},
|
||||||
onProgress: (loaded, total) => {
|
onProgress: (loaded, total) => {
|
||||||
if (params.onProgress) {
|
if (params.onProgress) {
|
||||||
|
|
@ -301,14 +341,26 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
fields: 'photo_100,photo_200,screen_name',
|
fields: 'photo_100,photo_200,screen_name',
|
||||||
}, authContext);
|
}, authContext);
|
||||||
|
|
||||||
const profileMap = new Map<number, VkUserProfile>(
|
const items = validateResponseItems(res, 'wall.getComments');
|
||||||
(res.profiles || []).map(p => [p.id, p])
|
const profileMap = new Map<number, VkUserProfile>();
|
||||||
);
|
if (res.profiles !== undefined) {
|
||||||
|
if (!Array.isArray(res.profiles)) throw incompleteVkItems('wall.getComments', 'profiles are malformed');
|
||||||
|
for (const rawProfile of res.profiles) {
|
||||||
|
const profile = validateProfile(rawProfile, 'wall.getComments');
|
||||||
|
if (profileMap.has(profile.id)) throw incompleteVkItems('wall.getComments', 'duplicate profile user ID');
|
||||||
|
profileMap.set(profile.id, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of res.items || []) {
|
for (const item of items) {
|
||||||
if (item.from_id && item.from_id > 0) {
|
const fromId = item.from_id;
|
||||||
const userId = String(item.from_id);
|
if (!Number.isSafeInteger(fromId) || fromId === 0) {
|
||||||
const prof = profileMap.get(item.from_id);
|
throw incompleteVkItems('wall.getComments', 'comment has an invalid author ID');
|
||||||
|
}
|
||||||
|
// VK group/community authors have negative IDs and are intentionally not participants.
|
||||||
|
if ((fromId as number) > 0) {
|
||||||
|
const userId = String(fromId);
|
||||||
|
const prof = profileMap.get(fromId as number);
|
||||||
const existing = participantsMap.get(userId);
|
const existing = participantsMap.get(userId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -333,7 +385,7 @@ export class VkProvider implements SocialMediaProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.items || [],
|
items: items as unknown as { from_id: number }[],
|
||||||
totalCount: res.count,
|
totalCount: res.count,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -196,4 +196,97 @@ describe('R3 import route persistence on incomplete VK data', () => {
|
||||||
const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50);
|
const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50);
|
||||||
expect(stored.participants).toEqual(previousParticipants);
|
expect(stored.participants).toEqual(previousParticipants);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects malformed likes/comments records at the import route without replacing stored participants', async () => {
|
||||||
|
const giveaway = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-42_7',
|
||||||
|
post: {
|
||||||
|
platform: 'VK', ownerId: '-42', postId: '7', sourceUrl: 'https://vk.com/wall-42_7',
|
||||||
|
title: 'Malformed response test', text: '', likesCount: 1, commentsCount: 0, repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: { ...DEFAULT_FILTER_RULES },
|
||||||
|
winnersCount: 1,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
organizerId: user.id,
|
||||||
|
});
|
||||||
|
const previousParticipants = [{
|
||||||
|
platformUserId: 'previous-user', firstName: 'Previous', lastName: 'Participant',
|
||||||
|
source: 'LIKES' as const, liked: true, commented: false, commentsCount: 0,
|
||||||
|
reposted: false, subscribed: false, eligible: true, exclusionReason: null,
|
||||||
|
}];
|
||||||
|
await GiveawayStore.updateParticipants(giveaway.id, previousParticipants);
|
||||||
|
|
||||||
|
const invalidCases: Array<{ label: string; method: 'likes.getList' | 'wall.getComments'; response: unknown }> = [
|
||||||
|
{ label: 'missing likes ID', method: 'likes.getList', response: { count: 1, items: [{ first_name: 'No', last_name: 'ID' }] } },
|
||||||
|
{ label: 'undefined likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: undefined }] } },
|
||||||
|
{ label: 'zero likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: 0 }] } },
|
||||||
|
{ label: 'negative likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: -3 }] } },
|
||||||
|
{ label: 'fractional likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: 1.5 }] } },
|
||||||
|
{ label: 'unsafe likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: Number.MAX_SAFE_INTEGER + 1 }] } },
|
||||||
|
{ label: 'string likes ID', method: 'likes.getList', response: { count: 1, items: [{ id: '101' }] } },
|
||||||
|
{ label: 'null likes item', method: 'likes.getList', response: { count: 1, items: [null] } },
|
||||||
|
{ label: 'duplicate likes IDs', method: 'likes.getList', response: { count: 2, items: [{ id: 101 }, { id: 101 }] } },
|
||||||
|
{ label: 'malformed optional likes profile field', method: 'likes.getList', response: { count: 1, items: [{ id: 101, first_name: {} }] } },
|
||||||
|
{ label: 'missing likes items', method: 'likes.getList', response: { count: 0 } },
|
||||||
|
{ label: 'missing likes count', method: 'likes.getList', response: { items: [] } },
|
||||||
|
{ label: 'missing comments items', method: 'wall.getComments', response: { count: 0 } },
|
||||||
|
{ label: 'null comment item', method: 'wall.getComments', response: { count: 1, items: [null] } },
|
||||||
|
{ label: 'comment missing author ID', method: 'wall.getComments', response: { count: 1, items: [{ id: 1 }] } },
|
||||||
|
{ label: 'zero comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: 0 }] } },
|
||||||
|
{ label: 'string comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: '101' }] } },
|
||||||
|
{ label: 'fractional comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: 1.5 }] } },
|
||||||
|
{ label: 'unsafe comment author ID', method: 'wall.getComments', response: { count: 1, items: [{ from_id: Number.MAX_SAFE_INTEGER + 1 }] } },
|
||||||
|
{ label: 'null comments profiles', method: 'wall.getComments', response: { count: 0, items: [], profiles: null } },
|
||||||
|
{ label: 'null comments profile item', method: 'wall.getComments', response: { count: 0, items: [], profiles: [null] } },
|
||||||
|
{ label: 'invalid comments profile ID', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: -101 }] } },
|
||||||
|
{ label: 'malformed comments profile field', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: 101, photo_100: 42 }] } },
|
||||||
|
{ label: 'duplicate comments profiles', method: 'wall.getComments', response: { count: 0, items: [], profiles: [{ id: 101 }, { id: 101 }] } },
|
||||||
|
{ label: 'missing comments count', method: 'wall.getComments', response: { items: [] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const invalidCase of invalidCases) {
|
||||||
|
expensiveApiRateLimiter.reset();
|
||||||
|
const client = {
|
||||||
|
call: vi.fn(async (method: string) => {
|
||||||
|
if (method === invalidCase.method) return invalidCase.response;
|
||||||
|
return { count: 0, items: [] };
|
||||||
|
}),
|
||||||
|
} as unknown as IVkClient;
|
||||||
|
vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue(
|
||||||
|
new VkProvider('fake-service-token-malformed-r3', client)
|
||||||
|
);
|
||||||
|
const req = new NextRequest(`http://localhost/api/giveaways/${giveaway.id}/participants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
filterRules: {
|
||||||
|
...DEFAULT_FILTER_RULES,
|
||||||
|
requireLike: invalidCase.method === 'likes.getList',
|
||||||
|
requireComment: invalidCase.method === 'wall.getComments',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const response = await importParticipants(req, { params: { id: giveaway.id } });
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status, invalidCase.label).toBe(502);
|
||||||
|
expect(body.error.code, invalidCase.label).toBe('VK_INCOMPLETE_DATA');
|
||||||
|
const stored = await GiveawayStore.getParticipantsPaginated(giveaway.id, 1, 50);
|
||||||
|
expect(stored.participants, invalidCase.label).toEqual(previousParticipants);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows valid negative comment authors as explicitly excluded community authors', async () => {
|
||||||
|
const client = {
|
||||||
|
call: vi.fn().mockResolvedValue({ count: 1, items: [{ id: 1, from_id: -42 }] }),
|
||||||
|
} as unknown as IVkClient;
|
||||||
|
const provider = new VkProvider('fake-service-token-negative-community', client);
|
||||||
|
|
||||||
|
await expect(provider.fetchParticipants({
|
||||||
|
ownerId: '-42', postId: '7', includeLikes: false, includeComments: true,
|
||||||
|
})).resolves.toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
115
tests/vk-smoke-provider-mode.test.ts
Normal file
115
tests/vk-smoke-provider-mode.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { ProviderFactory } from '../src/providers/factory';
|
||||||
|
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||||
|
import { POST as previewPost } from '../src/app/api/posts/preview/route';
|
||||||
|
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||||
|
import { generalApiRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); generalApiRateLimiter.reset(); });
|
||||||
|
|
||||||
|
describe('live smoke cannot accept generated VK data', () => {
|
||||||
|
it('rejects explicit mock configuration in production even with a service token', () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'production');
|
||||||
|
vi.stubEnv('USE_VK_MOCK', 'true');
|
||||||
|
vi.stubEnv('VK_SERVICE_TOKEN', 'FAKE_UNUSED_SERVICE_TOKEN');
|
||||||
|
expect(() => ProviderFactory.getVkProvider()).toThrow(/forbidden in production/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels an actual mock provider preview despite its SERVICE auth metadata', async () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'test');
|
||||||
|
vi.stubEnv('USE_VK_MOCK', 'true');
|
||||||
|
const id = await defaultSessionStore.createSession({ id: 'fake-smoke-mode-user', vkUserId: '9001' });
|
||||||
|
try {
|
||||||
|
const response = await previewPost(new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST', headers: { cookie: `${SESSION_COOKIE_NAME}=${id}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-1_1' }),
|
||||||
|
}));
|
||||||
|
const body = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
|
||||||
|
expect(body.providerMode).toBe('MOCK');
|
||||||
|
} finally { await defaultSessionStore.destroySession(id); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a real provider instance without exposing its token', async () => {
|
||||||
|
vi.stubEnv('NODE_ENV', 'test');
|
||||||
|
vi.stubEnv('USE_VK_MOCK', 'false');
|
||||||
|
vi.stubEnv('VK_SERVICE_TOKEN', 'FAKE_LIVE_MODE_MARKER_TOKEN');
|
||||||
|
vi.spyOn(VkProvider.prototype, 'fetchPost').mockResolvedValue({
|
||||||
|
platform: 'VK', ownerId: '-1', postId: '1', sourceUrl: 'https://vk.com/wall-1_1',
|
||||||
|
title: 'Fake response', text: '', likesCount: 2, commentsCount: 0, repostsCount: 0, resolvedAuthType: 'SERVICE',
|
||||||
|
});
|
||||||
|
const id = await defaultSessionStore.createSession({ id: 'fake-live-mode-user', vkUserId: '9002' });
|
||||||
|
try {
|
||||||
|
const response = await previewPost(new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST', headers: { cookie: `${SESSION_COOKIE_NAME}=${id}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-1_1' }),
|
||||||
|
}));
|
||||||
|
const text = await response.text();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(JSON.parse(text).providerMode).toBe('LIVE');
|
||||||
|
expect(text).not.toContain('FAKE_LIVE_MODE_MARKER_TOKEN');
|
||||||
|
} finally { await defaultSessionStore.destroySession(id); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ mode: 'MOCK', count: 2, expected: 1 },
|
||||||
|
{ mode: 'LIVE', count: 0, expected: 1 },
|
||||||
|
{ mode: 'LIVE', count: 2, expected: 0 },
|
||||||
|
])('smoke exits $expected for $mode mode with $count participants', async ({ mode, count, expected }) => {
|
||||||
|
let createCalls = 0;
|
||||||
|
let importCalls = 0;
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
if (req.url === '/api/auth/vk/start') {
|
||||||
|
res.writeHead(307, { location: 'https://id.vk.com/authorize?code_challenge_method=S256' }); res.end(); return;
|
||||||
|
}
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
if (req.url === '/api/auth/me') { res.end(JSON.stringify({ authenticated: true, user: { id: 'fake-user' } })); return; }
|
||||||
|
if (req.url === '/api/posts/preview') {
|
||||||
|
res.end(JSON.stringify({ success: true, providerMode: mode,
|
||||||
|
effectiveCapabilities: { accessMode: 'PUBLIC_SERVICE' }, post: { sourceUrl: 'https://vk.com/wall-1_1', resolvedAuthType: 'SERVICE' } })); return;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/giveaways') {
|
||||||
|
createCalls++; res.statusCode = 201; res.end(JSON.stringify({ success: true, giveaway: { id: 'fake-giveaway' } })); return;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/giveaways/fake-giveaway/participants') {
|
||||||
|
importCalls++; res.end(JSON.stringify({ success: true, providerMode: mode, totalCount: count })); return;
|
||||||
|
}
|
||||||
|
res.statusCode = 404; res.end('{}');
|
||||||
|
});
|
||||||
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
// Keep all local scratch material under the agent's permitted notes area.
|
||||||
|
const dir = await mkdtemp(path.resolve('agents/codex/notes/.smoke-regression-'));
|
||||||
|
try {
|
||||||
|
const port = (server.address() as { port: number }).port;
|
||||||
|
const file = path.join(dir, '.env.local');
|
||||||
|
await writeFile(file, `VK_SMOKE_BASE_URL=http://127.0.0.1:${port}\nVK_SMOKE_SESSION_COOKIE=randomayzer_session=FAKE_SMOKE_COOKIE\nVK_SMOKE_PUBLIC_POST_URL=https://vk.com/wall-1_1\nVK_SMOKE_GROUP_ID=42\nVK_SMOKE_CREATE_GIVEAWAY=YES\n`, { mode: 0o600 });
|
||||||
|
let stdout = '', exitCode = 0;
|
||||||
|
try { stdout = (await run('bash', [path.resolve('tools/vk-smoke.sh'), file], { timeout: 20000 })).stdout; }
|
||||||
|
catch (error) { const failure = error as { code: number; stdout: string }; exitCode = failure.code; stdout = failure.stdout; }
|
||||||
|
expect(exitCode).toBe(expected);
|
||||||
|
expect(stdout).toContain('MANUAL B');
|
||||||
|
expect(stdout).not.toContain('FAKE_SMOKE_COOKIE');
|
||||||
|
if (mode === 'MOCK') {
|
||||||
|
expect(stdout).toContain('FAIL D'); expect(stdout).not.toContain('PASS G'); expect(stdout).not.toContain('PASS H');
|
||||||
|
expect(createCalls).toBe(0); expect(importCalls).toBe(0);
|
||||||
|
} else if (count === 0) {
|
||||||
|
expect(stdout).toContain('FAIL G'); expect(stdout).toContain('FAIL H');
|
||||||
|
} else {
|
||||||
|
expect(stdout).toContain('PASS D'); expect(stdout).toContain('PASS G'); expect(stdout).toContain('PASS H');
|
||||||
|
expect(importCalls).toBe(2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
await new Promise<void>((resolve, reject) => server.close(err => err ? reject(err) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -91,6 +91,14 @@ json_success() {
|
||||||
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true ? 0 : 1)' "$BODY" >/dev/null 2>&1
|
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true ? 0 : 1)' "$BODY" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Marker is emitted by the server for the actual provider instance, not inferred from access mode.
|
||||||
|
json_live_success() {
|
||||||
|
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true && d.providerMode === "LIVE" ? 0 : 1)' "$BODY" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
json_live_import_success() {
|
||||||
|
node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.exit(d?.success === true && d.providerMode === "LIVE" && Number.isSafeInteger(d.totalCount) && d.totalCount > 0 ? 0 : 1)' "$BODY" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
# A: OAuth start creates a VK ID authorize redirect with PKCE S256.
|
# A: OAuth start creates a VK ID authorize redirect with PKCE S256.
|
||||||
request GET /api/auth/vk/start
|
request GET /api/auth/vk/start
|
||||||
if [[ "$HTTP_CODE" =~ ^30[1278]$ ]] && grep -Eiq '^location: https://id\.vk\.com/authorize[^\r]*code_challenge_method=S256' "$HEADERS"; then
|
if [[ "$HTTP_CODE" =~ ^30[1278]$ ]] && grep -Eiq '^location: https://id\.vk\.com/authorize[^\r]*code_challenge_method=S256' "$HEADERS"; then
|
||||||
|
|
@ -117,7 +125,7 @@ if [[ -z "$SESSION_COOKIE" || -z "$PUBLIC_POST_URL" ]]; then
|
||||||
else
|
else
|
||||||
payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PUBLIC_POST_URL")"
|
payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PUBLIC_POST_URL")"
|
||||||
request POST /api/posts/preview "$payload"
|
request POST /api/posts/preview "$payload"
|
||||||
if [[ "$HTTP_CODE" == 200 ]] && json_success; then
|
if [[ "$HTTP_CODE" == 200 ]] && json_live_success; then
|
||||||
access_mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.effectiveCapabilities?.accessMode||"")' "$BODY")"
|
access_mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.effectiveCapabilities?.accessMode||"")' "$BODY")"
|
||||||
if [[ "$access_mode" != PUBLIC_SERVICE ]]; then
|
if [[ "$access_mode" != PUBLIC_SERVICE ]]; then
|
||||||
result D FAIL 'public post preview did not report PUBLIC_SERVICE access mode'
|
result D FAIL 'public post preview did not report PUBLIC_SERVICE access mode'
|
||||||
|
|
@ -125,7 +133,7 @@ else
|
||||||
result D PASS 'public VK post preview succeeded with PUBLIC_SERVICE access mode'
|
result D PASS 'public VK post preview succeeded with PUBLIC_SERVICE access mode'
|
||||||
POST_JSON="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(JSON.stringify(d.post))' "$BODY")"
|
POST_JSON="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(JSON.stringify(d.post))' "$BODY")"
|
||||||
fi
|
fi
|
||||||
else result D FAIL "public post preview returned HTTP ${HTTP_CODE:-curl-error}"; fi
|
else result D FAIL "public post preview requires LIVE provider mode (HTTP ${HTTP_CODE:-curl-error})"; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$SESSION_COOKIE" || -z "$PRIVATE_POST_URL" ]]; then
|
if [[ -z "$SESSION_COOKIE" || -z "$PRIVATE_POST_URL" ]]; then
|
||||||
|
|
@ -133,10 +141,10 @@ if [[ -z "$SESSION_COOKIE" || -z "$PRIVATE_POST_URL" ]]; then
|
||||||
else
|
else
|
||||||
payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PRIVATE_POST_URL")"
|
payload="$(node -e 'process.stdout.write(JSON.stringify({url:process.argv[1],platform:"VK"}))' "$PRIVATE_POST_URL")"
|
||||||
request POST /api/posts/preview "$payload"
|
request POST /api/posts/preview "$payload"
|
||||||
if [[ "$HTTP_CODE" == 200 ]] && json_success; then
|
if [[ "$HTTP_CODE" == 200 ]] && json_live_success; then
|
||||||
mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.post?.resolvedAuthType||"")' "$BODY")"
|
mode="$(node -e 'const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(d.post?.resolvedAuthType||"")' "$BODY")"
|
||||||
if [[ "$mode" == USER ]]; then result E PASS 'restricted post preview used organizer authorization'; else result E FAIL 'restricted post did not resolve with USER authorization'; fi
|
if [[ "$mode" == USER ]]; then result E PASS 'restricted post preview used organizer authorization'; else result E FAIL 'restricted post did not resolve with USER authorization'; fi
|
||||||
else result E FAIL "restricted post preview returned HTTP ${HTTP_CODE:-curl-error}"; fi
|
else result E FAIL "restricted post preview requires LIVE provider mode (HTTP ${HTTP_CODE:-curl-error})"; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$ALLOW_CREATE" != YES ]]; then
|
if [[ "$ALLOW_CREATE" != YES ]]; then
|
||||||
|
|
@ -158,14 +166,14 @@ else
|
||||||
if [[ -n "$GIVEAWAY_ID" ]]; then
|
if [[ -n "$GIVEAWAY_ID" ]]; then
|
||||||
import_payload='{"filterRules":{"requireLike":true,"requireComment":true,"requireRepost":false,"requireSubscription":false,"excludeAdmins":false,"excludeBlacklistedIds":[],"minEligibleParticipants":1}}'
|
import_payload='{"filterRules":{"requireLike":true,"requireComment":true,"requireRepost":false,"requireSubscription":false,"excludeAdmins":false,"excludeBlacklistedIds":[],"minEligibleParticipants":1}}'
|
||||||
request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$import_payload"
|
request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$import_payload"
|
||||||
if [[ "$HTTP_CODE" == 200 ]] && json_success; then result G PASS 'participant import succeeded'; else result G FAIL "participant import returned HTTP ${HTTP_CODE:-curl-error}"; fi
|
if [[ "$HTTP_CODE" == 200 ]] && json_live_import_success; then result G PASS 'participant import succeeded'; else result G FAIL "participant import requires LIVE mode and nonempty participants (HTTP ${HTTP_CODE:-curl-error})"; fi
|
||||||
|
|
||||||
if [[ -z "$GROUP_ID" ]]; then
|
if [[ -z "$GROUP_ID" ]]; then
|
||||||
result H SKIP 'set VK_SMOKE_GROUP_ID to run subscription verification'
|
result H SKIP 'set VK_SMOKE_GROUP_ID to run subscription verification'
|
||||||
else
|
else
|
||||||
subscription_payload="$(node -e 'const p=JSON.parse(process.argv[1]); p.filterRules.requireSubscription=true; p.filterRules.targetGroupId=process.argv[2]; process.stdout.write(JSON.stringify(p))' "$import_payload" "$GROUP_ID")"
|
subscription_payload="$(node -e 'const p=JSON.parse(process.argv[1]); p.filterRules.requireSubscription=true; p.filterRules.targetGroupId=process.argv[2]; process.stdout.write(JSON.stringify(p))' "$import_payload" "$GROUP_ID")"
|
||||||
request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$subscription_payload"
|
request POST "/api/giveaways/$GIVEAWAY_ID/participants" "$subscription_payload"
|
||||||
if [[ "$HTTP_CODE" == 200 ]] && json_success; then result H PASS 'subscription verification returned a complete result'; else result H FAIL "subscription verification returned HTTP ${HTTP_CODE:-curl-error}"; fi
|
if [[ "$HTTP_CODE" == 200 ]] && json_live_import_success; then result H PASS 'subscription verification returned a complete result'; else result H FAIL "subscription verification requires LIVE mode and nonempty participants (HTTP ${HTTP_CODE:-curl-error})"; fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
result G SKIP 'requires a giveaway created by check F'
|
result G SKIP 'requires a giveaway created by check F'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue