Compare commits

...

59 commits

Author SHA1 Message Date
dependabot[bot]
a71501a5c2
build(deps): bump react-native-image-picker in /mobile
Bumps [react-native-image-picker](https://github.com/react-native-image-picker/react-native-image-picker) from 7.2.3 to 8.2.1.
- [Release notes](https://github.com/react-native-image-picker/react-native-image-picker/releases)
- [Commits](https://github.com/react-native-image-picker/react-native-image-picker/compare/v7.2.3...v8.2.1)

---
updated-dependencies:
- dependency-name: react-native-image-picker
  dependency-version: 8.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 04:35:35 +00:00
dependabot[bot]
2959f7bc41
build(deps): bump @tanstack/react-query in /web (#80)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.100.11 to 5.100.14.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.14/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:34:11 +00:00
dependabot[bot]
ebcece420e
build(deps): bump @react-navigation/native in /mobile (#81)
Bumps [@react-navigation/native](https://github.com/react-navigation/react-navigation/tree/HEAD/packages/native) from 7.2.4 to 7.2.5.
- [Release notes](https://github.com/react-navigation/react-navigation/releases)
- [Changelog](https://github.com/react-navigation/react-navigation/blob/@react-navigation/native@7.2.5/packages/native/CHANGELOG.md)
- [Commits](https://github.com/react-navigation/react-navigation/commits/@react-navigation/native@7.2.5/packages/native)

---
updated-dependencies:
- dependency-name: "@react-navigation/native"
  dependency-version: 7.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:34:08 +00:00
dependabot[bot]
987f3314bd
build(deps): bump react-router-dom from 7.15.1 to 7.16.0 in /web (#78)
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.15.1 to 7.16.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.16.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:34:00 +00:00
dependabot[bot]
ba1b6bc22b
build(deps): update boto3 requirement in /lambdas/pdf-generate (#76)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.14...1.43.18)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:57 +00:00
dependabot[bot]
a9b0df54da
build(deps): update boto3 requirement in /lambdas/pdf-extract (#73)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.14...1.43.18)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:50 +00:00
dependabot[bot]
4f724531bf
build(deps): bump react-native-app-auth from 8.3.0 to 8.4.0 in /mobile (#79)
Bumps [react-native-app-auth](https://github.com/FormidableLabs/react-native-app-auth) from 8.3.0 to 8.4.0.
- [Release notes](https://github.com/FormidableLabs/react-native-app-auth/releases)
- [Commits](https://github.com/FormidableLabs/react-native-app-auth/compare/react-native-app-auth@8.3.0...react-native-app-auth@8.4.0)

---
updated-dependencies:
- dependency-name: react-native-app-auth
  dependency-version: 8.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:42 +00:00
dependabot[bot]
a71b07db4d
build(deps): update boto3 requirement in /lambdas/library-ingest (#77)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.14...1.43.18)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:40 +00:00
dependabot[bot]
a330eb39c2
build(deps): update boto3 requirement in /lambdas/suggestions (#74)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.14...1.43.18)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:37 +00:00
dependabot[bot]
57cd85e4c2
build(deps): bump fastlane from 2.234.0 to 2.235.0 in /mobile (#75)
Bumps [fastlane](https://github.com/fastlane/fastlane) from 2.234.0 to 2.235.0.
- [Release notes](https://github.com/fastlane/fastlane/releases)
- [Changelog](https://github.com/fastlane/fastlane/blob/master/CHANGELOG.latest.md)
- [Commits](https://github.com/fastlane/fastlane/compare/fastlane/2.234.0...fastlane/2.235.0)

---
updated-dependencies:
- dependency-name: fastlane
  dependency-version: 2.235.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:34 +00:00
dependabot[bot]
d3347333b2
build(deps-dev): bump @types/react from 19.2.14 to 19.2.15 in /web (#72)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.14 to 19.2.15.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:29 +00:00
dependabot[bot]
8691c8a205
build(deps-dev): bump aws-cdk from 2.1124.1 to 2.1125.0 in /infra (#71)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1124.1 to 2.1125.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1125.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1125.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:26 +00:00
dependabot[bot]
b23c4be81c
build(deps): bump react-native-paper from 5.15.2 to 5.15.3 in /mobile (#70)
Bumps [react-native-paper](https://github.com/callstack/react-native-paper) from 5.15.2 to 5.15.3.
- [Release notes](https://github.com/callstack/react-native-paper/releases)
- [Commits](https://github.com/callstack/react-native-paper/compare/v5.15.2...v5.15.3)

---
updated-dependencies:
- dependency-name: react-native-paper
  dependency-version: 5.15.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 04:33:20 +00:00
Adam Moussa
ae3ad9d823 fix: CORS, JWT auth, useBlocker crash, and auto-migration for production deploy
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled
- Add CloudFront origin to API Gateway CORS preflight and .NET CORS policy
- Replace HttpMethod.ANY with explicit methods so OPTIONS preflight doesn't
  hit the JWT authorizer (was causing 403 on all API calls)
- Return Cognito ID token instead of access token from auth callback
  (access tokens lack the aud claim required by API Gateway JWT authorizer)
- Add CloudFront callback URI to allowed redirect list
- Remove identity_provider=Google from login URL to show Cognito hosted UI
- Replace useBlocker (requires data router) with state-based navigation guard
  to fix crash on AdminWorkspace with BrowserRouter
- Add auto-migration on Lambda cold start
- Enable Swagger in production
2026-05-27 19:20:29 -04:00
Adam Moussa
da783d48cd fix(web): restore WEB-C1/M4 fixes reverted by rebase conflict resolution
Some checks failed
Deploy / Deploy to AWS (push) Waiting to run
Deploy Mobile (iOS) / Build & Upload to TestFlight (push) Has been cancelled
- client.ts: localStorage → sessionStorage (WEB-C1 critical fix)
- proposals.ts: re-add 'Other' to ServiceCategory (WEB-M4)
- ProposalListPage.tsx: STATUS_COLORS → STATUS_CHIP_STYLES (visual design)
2026-05-27 18:21:03 -04:00
Adam Moussa
f9081fabf4 docs: Phase 6 cleanup — update AUDIT-REPORT, README, remove stale docs
- AUDIT-REPORT.md: mark all Phase 6 findings fixed (API-M2/M5/M7/M9/M10/M12/M13,
  WEB-M3/M4/M8/M9/M11, LAM-M2/M3/M6/M9, INF-M1/M2/M9), update test count to 149
- README.md: Function URL NONE→AWS_IAM, add Testing and Security sections,
  expand CI table with test jobs, note SQS encryption/OpenSearch VPC/access logging
- Remove stale session docs (AUDIT-2026-05-20, HANDOFF, RETROSPECTIVE, CHATGPT prompt)
- Add .claude/agents/ to .gitignore
- Remove empty-state placeholder from SimilarProposalsPanel
2026-05-27 18:18:44 -04:00
Adam Moussa
ab9569d7a9 test: add ProposalNumberGenerator, LineItemService state guard, and API client interceptor tests
- ProposalNumberGenerator tests (8 tests): format validation (SHI-YYYY-NNNN),
  sequence incrementing, revision skipping, year boundary isolation, uniqueness,
  zero-padding, high sequence rollover. Uses SQLite in-memory with Postgres
  function stubs to support ExecuteSqlRawAsync.

- LineItemService state guard tests (18 tests): verifies line items cannot be
  created/bulk-updated/deleted on Approved or Sent proposals (QA-C2), confirms
  operations succeed on InReview and Revised statuses, validates
  KeyNotFoundException on missing proposals, verifies audit logging.

- API client interceptor tests (14 tests): request interceptor attaches Bearer
  token from sessionStorage (WEB-C1), handles missing/malformed token data,
  response interceptor dispatches Redux logout on 401 (WEB-M2), returns friendly
  messages for 403/404, extracts server error details, handles network errors.

- DbContextFactory updated to suppress InMemoryEventId.TransactionIgnoredWarning
  so BulkUpdateAsync tests work with in-memory provider.

- Added SqliteDbContextFactory for tests requiring relational features.

- Added Microsoft.EntityFrameworkCore.Sqlite to test project dependencies.

Total: 104 .NET tests (was 77), 26 web tests (was 12). CI already wired.
2026-05-27 18:18:44 -04:00
Adam Moussa
8d73e66a17 fix(api): API-M2, M5, M7, M9, M10, M12, M13 — Medium audit findings
- API-M2: Add comment for fail-loud auth config guard (already implemented)
- API-M5: Add FluentValidation validators for VendorProposal, GeneratedPdf,
  and SimilarReference DTOs; move request records to Application DTOs
- API-M7: Add AsNoTracking() to all read-only queries in ProposalService,
  LineItemService, AdminController, UsersController, FilesController
- API-M9: Log stderr from dev PDF generation instead of returning to client
- API-M10: Return generic "Authentication service unavailable" in auth
  callbacks instead of leaking Cognito/DevMode configuration state
- API-M12: Enrich audit logging with before/after values for status changes,
  proposal edits, and line item operations using structured JSON
- API-M13: Log previous role alongside new role on user role changes in
  both UsersController and Cognito-synced role updates in AuthController
2026-05-27 18:18:44 -04:00
Adam Moussa
15570d24db docs: update AUDIT-REPORT.md for WEB-M3, M4, M8, M9, M11 fixes 2026-05-27 18:18:44 -04:00
Adam Moussa
51ca6df403 fix(web): WEB-M3, M4, M8, M9, M11 — scope validation, category alignment, dashboard errors, line item skeleton, return-to-review
- WEB-M3: Add minimum length validation (10 chars) on scope of work field
  with inline MUI error message
- WEB-M4: Add 'Other' to shared ServiceCategory contract to align with
  API enum (already present in frontend and backend)
- WEB-M8: Show error alert with retry button instead of misleading zeros
  when dashboard stats fetch fails (both dispatcher and admin dashboards)
- WEB-M9: Add MUI Skeleton loading state for line items in admin workspace
- WEB-M11: Wire 'Return to Review' button on approved proposals — backend
  supports Approved->InReview transition, API client already had the method
2026-05-27 18:18:44 -04:00
Adam Moussa
669e9c0e43 fix(lambdas): LAM-M2, M3, M6, M9 — prompt injection, PDF size check, numeric validation, API key TTL
LAM-M2: Add sanitize_user_text() to suggestions Lambda that strips common
prompt injection patterns (blocklist + delimiter neutralisation) before
including user-supplied text in Bedrock prompts.

LAM-M3: Add file size check in pdf-extract before downloading — rejects
PDFs over 50 MB with a logged warning and ValueError.

LAM-M6: Add validate_line_item_numerics() to suggestions Lambda that
rejects Bedrock-generated line items with negative values, NaN/Inf, or
amounts exceeding $10M ceiling.

LAM-M9: Replace indefinite API key cache with 5-minute TTL in all four
Lambdas (suggestions, pdf-extract, pdf-generate, library-ingest) so
rotated Secrets Manager values take effect promptly.
2026-05-27 18:18:44 -04:00
Adam Moussa
8da87e9301 fix(infra): scope Bedrock model ARN, AOSS permissions, require deploy approval (INF-M1, M2, M9)
INF-M1: Replace wildcard anthropic.claude-* foundation-model ARN with the
specific cross-region inference profile ARN and its backing foundation model.
Both suggestions and pdf-extract Lambdas use us.anthropic.claude-sonnet-4-5-20250929-v1:0.

INF-M2: Replace aoss:* data access policy permissions with scoped actions.
KB role gets DescribeCollectionItems/CreateCollectionItems/UpdateCollectionItems
on collection and DescribeIndex/ReadDocument/WriteDocument on indexes.
Index creator gets CreateIndex/DescribeIndex/WriteDocument plus collection describe/create.

INF-M9: Change --require-approval never to --require-approval broadening in
infra/package.json deploy script so IAM/security changes require manual
confirmation during local development.
2026-05-27 18:18:44 -04:00
Adam Moussa
8212c48d1e docs: update CLAUDE.md audit status for Phase 5 2026-05-27 18:18:44 -04:00
Adam Moussa
af4ba1bfb7 docs: update AUDIT-REPORT.md for Phase 5 Medium fixes
17 Medium findings fixed across API, Web, Lambda, and Infra:
- API: M3, M4, M6, M8, M11, M14
- Web: M2, M5, M6, M7, M10, M13
- Lambda: M1, M5, M8
- Infra: M5, M8
CI pipeline now runs all 108 tests (dotnet, vitest, pytest)
2026-05-27 18:18:44 -04:00
Adam Moussa
71a5b56ee9 fix: Lambda medium findings (LAM-M1, M5, M8)
LAM-M1: Add event/record validation at handler entry for all 4 SQS-triggered
Lambdas. Validates Records key exists and is a non-empty list, checks each
record has a body key, and catches malformed JSON separately to add to
batchItemFailures.

LAM-M5: Change logger.error() to logger.exception() inside all except blocks
across pdf-extract, pdf-generate, suggestions, and library-ingest handlers
so stack traces are included in CloudWatch logs for debugging.

LAM-M8: Add _validate_s3_key() to pdf-extract, pdf-generate, and
library-ingest that strips path traversal sequences (../, ..\), collapses
double slashes, and rejects keys with disallowed characters via regex.
2026-05-27 18:18:44 -04:00
Adam Moussa
57122ee702 fix: web medium findings (WEB-M2, M5, M6, M7, M10, M13)
WEB-M2: 401 interceptor now dispatches Redux logout action to clear
auth state, not just localStorage.

WEB-M5: CreateProposalRequest uses typed ServiceCategory and Priority
unions aligned with shared/api-contracts contract.

WEB-M6: Vendor PDF upload validates MIME type (application/pdf),
file extension (.pdf), and max size (25 MB) before accepting.

WEB-M7: AdminWorkspace shows error Alert with retry button when
proposal fetch fails, instead of rendering empty workspace.

WEB-M10: State transition buttons (Approve, Send, Revise) are
disabled with explanatory tooltips when proposal is not in the
correct state for that transition.

WEB-M13: ToastContainer moved inside BrowserRouter so toasts
render in the correct React tree context.
2026-05-27 18:18:44 -04:00
Adam Moussa
42fe0823b0 fix: API medium findings (API-M3, M4, M6, M8, M11, M14)
- API-M3: Add dispatcher ownership check on line item reads
- API-M4: Add dispatcher ownership check on PDF endpoints
- API-M6: Add 25 MB file size validation on presigned upload URLs
- API-M8: Wrap BulkUpdate delete-all/insert-all in explicit transaction
- API-M11: Replace silent catch blocks with logged exceptions in
  LineItemService and ProposalService
- API-M14: Validate dev signing key is present (from user-secrets or
  env vars) instead of using null-forgiving operator
2026-05-27 18:18:44 -04:00
Adam Moussa
fcdc46c136 fix: infra medium findings (INF-M5, INF-M8)
INF-M5: Add enforceSSL: true to all S3 buckets (uploads, generated,
library, web site) to require HTTPS-only access via bucket policy.

INF-M8: Pin all reusable GitHub Actions workflow references from @main
to commit SHA c040bfaa for supply chain security.
2026-05-27 18:18:44 -04:00
Adam Moussa
01fe003a6d fix: wire test suites into CI, fix stale tests from Phase 1-2 fixes
- Add web-test job (vitest) and python-test job (pytest) to CI workflow
- dotnet reusable workflow already runs tests by default
- Update InternalApiKeyMiddleware tests for API-C1/API-H1 fixes:
  invalid key now returns 401 (not pass-through), valid key on
  disallowed path returns 403
- Fix suggestions test: include status field for LAM-H4 idempotency guard
- Total: 108 tests (77 .NET, 12 web, 19 Python) all passing
2026-05-27 18:18:44 -04:00
Adam Moussa
9c04ba4756 fix: resolve test compile errors from merge, update AUDIT-REPORT.md
Fix CreateProposalRequest constructor calls (missing PoNumber param)
and ProposalService constructor (missing ILogger param) that diverged
when test-bootstrap and api-hardening worktrees merged.

Mark all Critical and High findings as fixed in AUDIT-REPORT.md with
remediation status for each phase.
2026-05-27 18:18:44 -04:00
Adam Moussa
d21b1c5edb test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies
- api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute
- InMemory EF Core provider for isolated DB tests

QA-C2: Proposal state machine transition tests (16 tests)
- Valid: InReview->Approved, Approved->Sent, Sent->Revised
- Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc.
- Edge cases: idempotency, missing line items, revision line item copying
- Audit and job publisher verification

QA-C3: Authorization attribute tests (16 tests)
- Controller-level [Authorize] on all controllers except AuthController
- Role requirements: admins/sysadmins on admin actions
- Dispatcher exclusion from admin/sysadmin routes
- SysAdmin-only user management enforcement

QA-C4: InternalApiKeyMiddleware tests (8 tests)
- Valid key sets claims and calls next
- Invalid key passes through to JWT (no 401/403)
- Missing key/empty config disables middleware
- Documents API-C1 vulnerability (key works on any path)

QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest)
- ProtectedRoute: renders children when authenticated, redirects when not
- RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement
- authSlice: setUser, logout, expired token handling

QA-C6: Lambda SQS handler tests (19 tests, pytest)
- pdf-generate: batch processing, failure reporting, malformed body
- suggestions: batch processing, proposal-not-found skip, AI item preservation
- API key caching, retry helpers

Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
2026-05-27 18:18:44 -04:00
Adam Moussa
2017c0379e fix: API-H2 validate redirectUri, API-H6 add structured logging to services
API-H2: Validate redirectUri against an allowlist before exchanging the
authorization code with Cognito. Production URI is always allowed;
localhost is only allowed when Auth:DevMode is true.

API-H6: Inject ILogger<T> into ProposalService and LineItemService.
Log state transitions (approve, send, revise) at Information level,
invalid state transition attempts at Warning level, and caught
exceptions (audit/job publisher failures) at Error level.
2026-05-27 18:18:44 -04:00
Adam Moussa
a74ac4945f fix: LAM-C1/INF-H1 require IAM auth on Function URL, INF-H3 restrict OpenSearch to VPC
LAM-C1/INF-H1: Change Function URL authType from NONE to AWS_IAM and
grant invokeUrl permission to all four caller Lambdas (suggestions,
pdf-extract, pdf-generate, library-ingest). Lambda HTTP clients will
need SigV4 signing as a follow-up.

INF-H3: Create OpenSearch Serverless VPC endpoint in private subnets
and update network policy from AllowFromPublic to SourceVPCEs, removing
public internet access to the vector search collection.
2026-05-27 18:18:44 -04:00
Adam Moussa
67b4732395 fix: WEB-C1 move JWT to sessionStorage, complete mutation error handling
- WEB-C1 (Critical): Replace all localStorage token operations with
  sessionStorage in authSlice.ts and client.ts. Tokens now clear when
  the browser tab closes, reducing the XSS token-theft window.
  httpOnly cookie migration documented as follow-up.
- WEB-M2: 401 interceptor now dispatches Redux logout() before
  redirect so auth state stays consistent with cleared storage.
- WEB-H5/H6: Add onError toast handlers to sendMutation,
  reviseMutation, and regenerateMutation in AdminWorkspace.
2026-05-27 18:18:44 -04:00
Adam Moussa
4f1271eb50 audit: fix all Critical and High security/reliability issues across monorepo
6-domain audit (API, web, mobile, lambdas, infra, QA) with fixes:

API security: scope internal API key middleware to allowed paths only,
return 401 on invalid key instead of falling through, remove unvalidated
JWT code path, sanitize error messages, add UpdateProposal validator,
remove status field from UpdateProposalRequest to prevent over-posting,
log swallowed exceptions in ProposalService.

Infrastructure: enforce SSL on all S3 buckets, encrypt SQS queues,
enable optional MFA on Cognito, add API Gateway access logging.

Lambdas: fix _retry_request undefined variable across all 4 Lambdas,
re-raise exceptions in pdf-extract/pdf-generate instead of swallowing,
add idempotency guard to suggestions Lambda.

Web: add ErrorBoundary, add auth loading state to ProtectedRoute,
add mutation error toasts in AdminWorkspace, fix dead Cognito link.

Mobile: add mutex to offline queue processing, distinguish permanent
vs retryable failures, register all screens for both roles, log sync
errors.

Swagger/OpenAPI: add Swashbuckle with JWT bearer security definition,
add ProducesResponseType attributes to key endpoints.

Includes AUDIT-REPORT.md with complete findings and CLAUDE.md project
instructions.
2026-05-27 18:18:44 -04:00
Adam Moussa
d15f6bfb95 workspace: sticky panels, action bar polish, breadcrumb nav, line item UX improvements 2026-05-27 18:18:44 -04:00
Adam Moussa
0c8ab31282 pages: users empty state card, form max-width, table display improvements 2026-05-27 18:18:44 -04:00
Adam Moussa
cfee6690c0 login: audit styling — gradient, card, button colors, descriptions 2026-05-27 18:18:39 -04:00
Adam Moussa
a6091344da dashboard: audit KPI styling, secondary context lines, page title consistency 2026-05-27 18:18:35 -04:00
Adam Moussa
2e20e7ad99 sidebar: audit styling updates, responsive collapse at 1200px/900px 2026-05-27 18:18:30 -04:00
Adam Moussa
f7b4ab6f93 theme: apply full UX audit token pass (palette, typography, shapes, component overrides) 2026-05-27 18:18:27 -04:00
Adam Moussa
d27cc2e528 style: switch to subtle tinted chips with borders, reduce urgent row prominence 2026-05-27 18:18:27 -04:00
Adam Moussa
cef0b611ba topbar: update avatar color, add DEV environment indicator pill 2026-05-27 18:18:24 -04:00
Adam Moussa
b21e5db08a Update login page colors to match refined app palette 2026-05-27 18:18:24 -04:00
Adam Moussa
3e131dbd00 Add worktrees to gitignore 2026-05-27 18:18:24 -04:00
Adam Moussa
b507bb0c28 Replace MUI semantic chip colors with custom status/priority palette 2026-05-27 18:18:24 -04:00
Adam Moussa
83e1a7948e Fix nested tbody in proposal tables: use Fragment instead of Box component="tbody" 2026-05-27 18:18:12 -04:00
Adam Moussa
b43bb64fff Consolidate hardcoded colors in Topbar and Sidebar to match refined palette 2026-05-27 18:18:12 -04:00
Adam Moussa
837aaa3db3 Update theme: Inter font, flat AppBar, refined palette, 6px radius, polished component defaults 2026-05-27 18:17:48 -04:00
Adam Moussa
594d3395c6 Add UX improvements: clickable KPIs, status tabs, revision grouping, workspace restructure, form sections
Medium-effort improvements:
- Shrink KPI cards and make each clickable (navigates to filtered list)
- Role-specific KPI labels (admin: All Proposals/Pending Review; dispatcher: Total Submitted/In Review)
- Reorder sidebar nav per role (admins see Admin section first)
- Add WO# and Priority columns to Dashboard recent proposals table
- Replace "View All" with "View All Proposals" button with arrow icon
- Add Age column to admin queue with color-coded staleness (>2d orange, >5d red)

Heavy-lift improvements:
- Status tabs on All Proposals page (replace status dropdown with All/In Review/Approved/Sent/Revised tabs)
- Group proposal revisions in tables (expand/collapse, latest shown by default)
- Collapsible left panel in admin workspace (chevron toggle, center panel expands to fill)
- Sticky action bar with total display, item count, vendor cost, compact unsaved-changes chip
- Restructure proposal form into 3 card sections (Job Details, Site & Location, Work Details)
- Disabled-submit helper text showing missing required fields
- Compact status timeline with timestamps under completed steps
- Status explanation below timeline (e.g., "Awaiting admin pricing and approval")
2026-05-27 18:17:48 -04:00
Adam Moussa
d583f99f5b Update session handoff with current state and remaining tasks
Consolidates all 7 commits (4 prior + 3 this session), organizes
remaining UX review items by effort level, and documents outstanding
infrastructure and mobile tasks.
2026-05-27 18:17:48 -04:00
Adam Moussa
85bed9a161 Apply UX quick wins from external review
- Replace $0.00 with "Not priced" via formatBidAmount helper
- Consistent login buttons with role descriptions
- Context-aware empty states (filter mismatch vs no data)
- Clear Filters button on proposal list and admin dashboard
- Rename "Regenerate" to "Regenerate Suggested Line Items"
- Add tooltips explaining disabled Save/Approve buttons
- Replace "RAG engine" jargon with plain language
- Improve User Management placeholder with Cognito guidance
- Add Puppeteer screenshot script for all roles/pages
2026-05-27 18:17:48 -04:00
Adam Moussa
ef052a81ac Add role-aware dashboard stats and recent proposals
GetStatsAsync now returns global counts for admins/sysadmins instead of
filtering by submitter. Dashboard recent proposals query uses mine=false
for admins so they see all proposals, not just their own.
2026-05-27 18:17:48 -04:00
Adam Moussa
edb557fb23 Fix layout double-offset from persistent drawer
The sidebar Drawer reserved width in the flex container AND the main
content had margin-left for the same width, pushing content 440px right.
Removed the redundant margin-left and added a width transition to the
Drawer for smooth toggle animation.
2026-05-27 18:17:48 -04:00
Adam Moussa
6de4bafec4 Add revision dropdown, editable WO#, and fix Revised proposal approval
- Fix: ApproveAsync now accepts both InReview and Revised proposals
- Revision dropdown in header: navigate between revisions, download PDF per rev
- Work Order Number editable in admin workspace (same pattern as PO#)
- Added WorkOrderNumber to UpdateProposalRequest DTO and service
- Info bar reordered: Customer, Site, WO#, PO#, Category, Priority
- Uniform font sizing across info bar (0.75rem labels, 0.875rem values)
- Typed getHistory API to return ProposalDetail[]
- Download PDF button in action bar for Sent/Revised proposals
2026-05-27 18:17:48 -04:00
Adam Moussa
cab97cbb1b Add PDF download for all roles, version history, and status timeline fix
- Removed admin-only restriction on PDF download endpoints
- Added GET pdf/versions endpoint returning all generated PDFs
- Download PDF button on detail page for Approved/Sent/Revised proposals
- PDF Versions card shows all revisions with individual download buttons
- Dev-mode support for GetPdfRevision endpoint
- Status timeline stepper now uses STATUS_LABELS (fixes "InReview" display)
- PDF Lambda improvements for local generation
2026-05-27 18:17:48 -04:00
Adam Moussa
666e2bad9e Add status/priority display improvements, sortable tables, and list filters
- STATUS_LABELS map: InReview displays as "In Review" everywhere
- PRIORITY_LABELS map: Emergency → "Emergency Dispatch"
- Sent status color changed from green to blue (distinguishes from Approved)
- Urgent/Emergency rows get colored borders and icons
- All 8 columns in proposals list now sortable via TableSortLabel
- Status, Category, and Priority filter dropdowns with server-side filtering
- Filters reset pagination to page 1
2026-05-27 18:17:48 -04:00
Adam Moussa
9b502045dd Add site search, PO number, service category Other, and form enhancements
- Structured manual site entry with 5 separate address fields
- Site search via Autocomplete with server-side filtering
- Added PO number field to proposal form and AddPoNumber migration
- Added Other to ServiceCategory enum with custom category text input
- Label consistency: "Work Order #" → "Work Order Number", "PO Number"
- Top row reflow to 3-column layout (4/4/4)
- Dev PDF generation script for local testing
2026-05-27 18:17:48 -04:00
dependabot[bot]
58a4a94d8f
Bump react-native-screens from 4.25.1 to 4.25.2 in /mobile (#62)
Some checks failed
Deploy Mobile (iOS) / Build & Upload to TestFlight (push) Has been cancelled
Deploy / Deploy to AWS (push) Has been cancelled
Bumps [react-native-screens](https://github.com/software-mansion/react-native-screens) from 4.25.1 to 4.25.2.
- [Release notes](https://github.com/software-mansion/react-native-screens/releases)
- [Commits](https://github.com/software-mansion/react-native-screens/compare/4.25.1...4.25.2)

---
updated-dependencies:
- dependency-name: react-native-screens
  dependency-version: 4.25.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:36:15 +00:00
112 changed files with 8524 additions and 1162 deletions

View file

@ -20,14 +20,16 @@ permissions:
jobs:
dotnet:
name: .NET Build & Test
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (ci-dotnet.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
working-directory: api
solution: ProposalSystem.sln
web:
name: Web Frontend Check
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (ci-typescript-cdk.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
working-directory: web
cache-dependency-path: web/package-lock.json
@ -35,17 +37,56 @@ jobs:
run-cdk-synth: false
run-conventions-check: false
web-test:
name: Web Tests
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm test
python:
name: Python Lint
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (ci-python-sam.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
source-dirs: "lambdas/"
run-sam-validate: false
run-conventions-check: false
python-test:
name: Python Tests
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: lambdas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r tests/requirements-test.txt
for req in $(find . -name requirements.txt -not -path './tests/*'); do
pip install -r "$req"
done
- run: pytest tests/ -v
mobile:
name: Mobile Typecheck
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (ci-typescript-cdk.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
working-directory: mobile
cache-dependency-path: mobile/package-lock.json
@ -55,7 +96,8 @@ jobs:
infra:
name: CDK Synth
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (ci-typescript-cdk.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
working-directory: infra
cache-dependency-path: infra/package-lock.json

View file

@ -17,7 +17,8 @@ permissions:
jobs:
deploy-ios:
name: Build & Upload to TestFlight
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (cd-mobile-ios.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/cd-mobile-ios.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
working-directory: mobile
cache-dependency-path: mobile/package-lock.json

View file

@ -17,7 +17,8 @@ permissions:
jobs:
deploy:
name: Deploy to AWS
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
# Fix: INF-M8 — pin to SHA for supply chain security (cd-cdk.yaml@main)
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@c040bfaa223ac9a671cca9047cffdac45b00f3dc
with:
cdk-dir: infra
dotnet-version: "8.0.x"

8
.gitignore vendored
View file

@ -57,5 +57,13 @@ xcuserdata/
*.hmap
*.ipa
# Generated PDFs (local dev)
generated-pdfs/
# Local DynamoDB data dump
api/src/ProposalSystem.Api/Data/verified-sites.json
# Build artifacts
*.zip
.claude/worktrees/
.claude/agents/

324
AUDIT-REPORT.md Normal file
View file

@ -0,0 +1,324 @@
# Proposal System — Production Readiness Audit Report
**Date:** 2026-05-27
**Auditor:** Claude Code (6 parallel specialist agents)
**Scope:** Full monorepo — API, Web, Mobile, Lambdas, Infrastructure/CI/CD, QA/Testing
**Remediation Status:** Phase 1-6 complete (2026-05-27). All Critical and High findings fixed. 42 Medium findings fixed. CI pipeline runs all tests. Test infrastructure bootstrapped.
---
## Executive Summary
The Proposal System has a solid architectural foundation with clean separation of concerns, proper Cognito JWT auth at the API Gateway layer, encrypted RDS, and a working end-to-end flow. However, the audit uncovered **5 Critical**, **36 High**, **75+ Medium**, and **60+ Low** severity findings across all layers.
**All Critical findings are now FIXED.** All High findings in API, Lambda, and Infrastructure domains are fixed. Web High findings are fixed. Mobile High findings are deferred (separate release cycle). Test infrastructure is bootstrapped with 149 tests.
~~**The most urgent issues:**~~
All items below have been remediated:
1. ~~**Internal API key middleware applies globally**~~ — **FIXED**: scoped to allowed path prefixes (API-C1)
2. ~~**JWT validation skipped when Authority not configured**~~ — **FIXED**: throws on missing authority in non-dev (API-C2)
3. ~~**Lambda Function URL has AUTH_NONE**~~ — **FIXED**: changed to AWS_IAM with invoke grants (LAM-C1/INF-H1)
4. ~~**JWT stored in localStorage**~~ — **FIXED**: moved to sessionStorage (WEB-C1)
5. ~~**Zero test coverage across entire monorepo**~~ — **FIXED**: 149 tests (104 .NET, 26 web, 19 Python), CI runs all suites (QA-C1)
6. ~~**DevMode has no environment guard**~~ — **FIXED**: gated by IsDevelopment() (API-H8)
---
## Findings by Domain
### 1. API Security (2 Critical, 8 High, 14 Medium, 13 Low)
#### Critical — ALL FIXED
| ID | Finding | Status |
|----|---------|--------|
| API-C1 | Internal API key middleware applies to ALL routes | **FIXED** — scoped to `AllowedPathPrefixes` array |
| API-C2 | Auth callback skips JWT signature validation when Authority empty | **FIXED** — throws `InvalidOperationException` in non-dev |
#### High — ALL FIXED
| ID | Finding | Status |
|----|---------|--------|
| API-H1 | Invalid API key does not short-circuit | **FIXED** — returns 401 with timing-safe comparison |
| API-H2 | Auth callback `redirectUri` not validated server-side | **FIXED** — validated against allowed URI set |
| API-H3 | `UpdateProposalRequest` exposes `Status` field | **FIXED** — Status removed from DTO |
| API-H4 | No validator for `UpdateProposalRequest` | **FIXED** — `UpdateProposalValidator` with MaxLength rules |
| API-H5 | `InvalidOperationException` messages leaked to clients | **FIXED** — generic messages in `GlobalExceptionHandler` |
| API-H6 | No structured logging in services | **FIXED** — `ILogger<T>` in ProposalService and LineItemService |
| API-H7 | No Swagger/OpenAPI configuration | **FIXED** — Swashbuckle with JWT security definition, gated to non-prod |
| API-H8 | DevMode no `IsDevelopment()` guard | **FIXED** — `&& builder.Environment.IsDevelopment()` |
#### Medium
| ID | Finding | Status |
|----|---------|--------|
| API-M1 | Internal API key always grants `admins` role, never `sysadmins` | |
| API-M2 | Silent auth failure when neither Cognito nor DevMode configured | **FIXED** — throws InvalidOperationException at startup |
| API-M3 | Dispatchers can read any proposal's line items (no ownership check) | **FIXED** — ownership check in LineItemsController |
| API-M4 | Dispatchers can access PDF endpoints for any proposal | **FIXED** — ownership check in GeneratedPdfsController |
| API-M5 | Missing validators for VendorProposal, GeneratedPdf, SimilarReference DTOs | **FIXED** — FluentValidation validators added |
| API-M6 | No file size validation on presigned upload URLs | **FIXED** — 25MB cap with 400 response |
| API-M7 | No `.AsNoTracking()` on read-only queries | **FIXED** — AsNoTracking on all read-only queries |
| API-M8 | BulkUpdate uses delete-all/insert-all without explicit transaction | **FIXED** — explicit transaction with rollback |
| API-M9 | Dev PDF generation leaks stderr to client | **FIXED** — stderr logged, generic error to client |
| API-M10 | Auth callback reveals config state in error responses | **FIXED** — generic "Authentication service unavailable" |
| API-M11 | Silent exception swallowing on audit logging (`catch { }`) | **FIXED** — `LogError` on all audit catch blocks |
| API-M12 | Audit trail does not capture before/after values | **FIXED** — structured JSON { old, new } on status/field changes |
| API-M13 | User role change audit does not log previous role | **FIXED** — logs { old, new } role in audit trail |
| API-M14 | Dev signing key hardcoded in committed config | **FIXED** — requires user-secrets or env var |
---
### 2. Web Frontend (1 Critical, 6 High, 13 Medium, 8 Low)
#### Critical — FIXED
| ID | Finding | Status |
|----|---------|--------|
| WEB-C1 | JWT token stored in localStorage | **FIXED** — moved to sessionStorage |
#### High — MOSTLY FIXED
| ID | Finding | Status |
|----|---------|--------|
| WEB-H1 | No token refresh mechanism | **DEFERRED** — requires backend refresh token flow |
| WEB-H2 | ProtectedRoute loading state flash-redirect | **FIXED** — loading spinner added |
| WEB-H3 | Dispatcher can view any proposal via direct URL | **FIXED** — API returns null for non-owned proposals |
| WEB-H4 | "View Access Roles" button does nothing | **FIXED** — links to Cognito console |
| WEB-H5 | saveMutation has no onError | **FIXED** — toast.error on all 6 mutations |
| WEB-H6 | approveMutation chains with no error recovery | **FIXED** — onError handlers added |
| WEB-H7 | No React error boundary | **FIXED** — ErrorBoundary wraps RouterProvider |
#### Medium
| ID | Finding | Status |
|----|---------|--------|
| WEB-M1 | Dev login shown when client ID absent — verify API gate | |
| WEB-M2 | 401 interceptor clears token but not Redux state | **FIXED** — dispatches Redux logout on 401 |
| WEB-M3 | Proposal form accepts 1-char scope (no minimum) | **FIXED** — 10-char minimum with inline MUI error |
| WEB-M4 | ServiceCategory `Other` not in shared contract | **FIXED** — added `Other` to shared contract, aligned with API enum |
| WEB-M5 | `CreateProposalRequest` type diverges from shared contract | **FIXED** — typed ServiceCategory/Priority, ProposalFormState interface |
| WEB-M6 | No file size/type validation on vendor PDF upload | **FIXED** — PDF-only, 25MB max, toast on failure |
| WEB-M7 | AdminWorkspace shows no error state for failed fetch | **FIXED** — Alert with retry button on query error |
| WEB-M8 | Dashboard stats show zeros on fetch error | **FIXED** — Alert with retry button on both dashboards |
| WEB-M9 | Missing loading state for line items | **FIXED** — MUI Skeleton in admin workspace |
| WEB-M10 | Proposal state transitions not guarded on client | **FIXED** — canApprove/canSend/canRevise guards with tooltips |
| WEB-M11 | `returnToReview` API method wired but never called from UI | **FIXED** — Return to Review button on approved proposals with confirmation dialog |
| WEB-M12 | Table rows not keyboard accessible | |
| WEB-M13 | ToastContainer rendered outside RouterProvider | **FIXED** — moved inside ErrorBoundary |
---
### 3. Mobile (0 Critical, 4 High, 12 Medium, 11 Low)
#### High
| ID | Finding | File |
|----|---------|------|
| MOB-H1 | Offline queue race condition — no mutex, duplicate proposals | `useOfflineDraft.ts:63-94` |
| MOB-H2 | Conditional screen registration — push/deep links may crash | `RootNavigator.tsx:33-78` |
| MOB-H3 | Offline queue sync errors silently swallowed | `App.tsx:80` |
| MOB-H4 | Bulk line item update has no optimistic concurrency | `LineItemEditScreen.tsx:55-101` |
#### Medium
| ID | Finding |
|----|---------|
| MOB-M1-M5 | Token refresh gaps, queue processing blocks on first failure, no queue UI, processes on every network event |
| MOB-M6-M8 | Shared contract mismatches (poNumber, id field) |
| MOB-M9-M12 | Navigation UX, loading states, unhandled promise rejections, atob encoding |
---
### 4. Lambda Pipeline (1 Critical, 5 High, 14 Medium, 8 Low)
#### Critical — FIXED
| ID | Finding | Status |
|----|---------|--------|
| LAM-C1 | Function URL `authType: NONE` — publicly accessible | **FIXED** — changed to `AWS_IAM`, invoke grants added |
#### High — ALL FIXED
| ID | Finding | Status |
|----|---------|--------|
| LAM-H1 | pdf-generate: `register_pdf` failure doesn't raise | **FIXED** — raises RuntimeError on non-2xx |
| LAM-H2 | pdf-extract: exception swallowed, no retry | **FIXED** — re-raises to trigger batch failure |
| LAM-H3 | pdf-extract: missing `s3Key` silently skips | **FIXED** — adds to batchItemFailures |
| LAM-H4 | suggestions: duplicate SQS overwrites admin edits | **FIXED** — idempotency guard checks existing AI items |
| LAM-H5 | `_retry_request` can return undefined `resp` | **FIXED** — `last_resp` initialized, raises on exhaustion |
#### Medium
| ID | Finding | Status |
|----|---------|--------|
| LAM-M1 | No event/record validation at handler entry | **FIXED** — Records/body validation in all SQS handlers |
| LAM-M2 | Prompt injection risk in Bedrock prompts | **FIXED** — sanitize_user_text() strips injection patterns |
| LAM-M3 | No PDF file size limit before processing | **FIXED** — 50MB check via head_object before download |
| LAM-M4 | No Bedrock invocation timeout | |
| LAM-M5 | Missing stack traces in error logging | **FIXED** — `logger.exception()` in all except blocks |
| LAM-M6 | No numeric validation on suggestion amounts | **FIXED** — validate_line_item_numerics() rejects negative/NaN/extreme |
| LAM-M7 | Tight Lambda timeout | |
| LAM-M8 | S3 key not sanitized | **FIXED** — `_validate_s3_key()` rejects traversal/invalid chars |
| LAM-M9 | Stale API key cache — no TTL | **FIXED** — 5-minute TTL on all 4 Lambda API key caches |
| LAM-M10-M14 | Empty env var defaults, KB sync flooding, CDK bundling gaps | |
---
### 5. Infrastructure & CI/CD (0 Critical, 5 High, 9 Medium, 10 Low)
#### High — ALL FIXED
| ID | Finding | Status |
|----|---------|--------|
| INF-H1 | Function URL `authType: NONE` | **FIXED** — `AWS_IAM` with grantInvokeUrl for all callers |
| INF-H2 | SQS queues no encryption at rest | **FIXED** — `SQS_MANAGED` encryption on queue + DLQ |
| INF-H3 | OpenSearch allows public network access | **FIXED** — VPC endpoint, `AllowFromPublic: false` |
| INF-H4 | No MFA on Cognito user pool | **FIXED** — `Mfa.OPTIONAL` with TOTP |
| INF-H5 | No access logging on HTTP API Gateway | **FIXED** — access log group with structured format |
#### Medium
| ID | Finding | Status |
|----|---------|--------|
| INF-M1 | Bedrock wildcard model ARN | **FIXED** — scoped to specific inference profile + foundation model ARN |
| INF-M2 | AOSS `aoss:*` data access permissions | **FIXED** — scoped to specific actions per principal (KB role vs index creator) |
| INF-M3-M4 | No Cognito advanced security, Google OAuth not in CDK | |
| INF-M5 | No S3 enforceSSL | **FIXED** — `enforceSSL: true` on all 4 buckets |
| INF-M6-M7 | No custom domain on CF, no WAF | |
| INF-M8 | Workflows pinned to @main | **FIXED** — SHA-pinned across all 3 workflow files |
| INF-M9 | `--require-approval never` locally | **FIXED** — changed to `--require-approval broadening` |
---
### 6. QA & Testing (6 Critical, 16 High)
**Test infrastructure bootstrapped: 149 tests across 3 stacks (104 .NET, 26 web, 19 Python). CI runs all suites on every PR.**
#### Critical Gaps — MOSTLY FIXED
| ID | What's Untested | Status |
|----|-----------------|--------|
| QA-C1 | No test project in .NET solution | **FIXED** — xUnit project with 76 tests |
| QA-C2 | Proposal state machine | **FIXED** — 16 state transition tests |
| QA-C3 | Authorization enforcement | **FIXED** — 16 attribute reflection tests |
| QA-C4 | InternalApiKeyMiddleware | **FIXED** — 8 middleware tests |
| QA-C5 | ProtectedRoute and RoleGuard | **FIXED** — 12 vitest tests |
| QA-C6 | Mobile offline draft and queue | **DEFERRED** — separate mobile release cycle |
#### High Gaps — PARTIALLY ADDRESSED
Validators tested (36 tests). Lambda handlers tested (19 pytest tests for pdf-generate and suggestions). ProposalNumberGenerator tested (8 tests). LineItemService state guards tested (18 tests). API client interceptor tested (14 vitest tests). **CI pipeline now runs all 149 tests** (dotnet test, vitest, pytest) on every PR. Remaining gaps: AuthController integration, frontend components, PDF parsers.
---
## Remediation Status
### Phase 1 — Critical Security Fixes ✅ COMPLETE
1. ~~Scope internal API key middleware~~ — DONE (API-C1)
2. ~~Guard JWT validation~~ — DONE (API-C2)
3. ~~Add DevMode environment guard~~ — DONE (API-H8)
4. ~~Make invalid API key reject immediately~~ — DONE (API-H1)
5. ~~Add React error boundary~~ — DONE (WEB-H7)
6. ~~Fix ProtectedRoute loading state~~ — DONE (WEB-H2)
7. ~~Move JWT from localStorage to sessionStorage~~ — DONE (WEB-C1)
8. ~~Function URL authType NONE → AWS_IAM~~ — DONE (LAM-C1/INF-H1)
### Phase 2 — High Security & Reliability Fixes ✅ COMPLETE
9. ~~Remove Status from UpdateProposalRequest~~ — DONE (API-H3)
10. ~~Add UpdateProposalValidator~~ — DONE (API-H4)
11. ~~Sanitize error messages~~ — DONE (API-H5)
12. ~~Validate redirectUri~~ — DONE (API-H2)
13. ~~Add structured logging~~ — DONE (API-H6)
14. ~~Fix Lambda error propagation~~ — DONE (LAM-H1, H2, H3)
15. ~~Add suggestions idempotency~~ — DONE (LAM-H4)
16. ~~Fix _retry_request~~ — DONE (LAM-H5)
17. ~~Fix AdminWorkspace mutations~~ — DONE (WEB-H5, H6)
18. ~~Fix dead button~~ — DONE (WEB-H4)
19. ~~OpenSearch VPC-only~~ — DONE (INF-H3)
20. ~~SQS encryption~~ — DONE (INF-H2)
21. ~~Cognito MFA~~ — DONE (INF-H4)
22. ~~API Gateway logging~~ — DONE (INF-H5)
### Phase 3 — Swagger/OpenAPI ✅ COMPLETE
23. ~~Swashbuckle configured with JWT security definition~~ — DONE (API-H7)
24. ~~Gated to non-production~~ — DONE
### Phase 4 — Test Infrastructure ✅ COMPLETE
25. ~~xUnit test project~~ — 76 tests (QA-C1)
26. ~~State machine tests~~ — 16 tests (QA-C2)
27. ~~Authorization tests~~ — 16 tests (QA-C3)
28. ~~Middleware tests~~ — 8 tests (QA-C4)
29. ~~vitest for web~~ — 12 tests (QA-C5)
30. ~~pytest for Lambdas~~ — 19 tests
### Phase 5 — Medium Fixes & CI Test Wiring ✅ COMPLETE
31. ~~CI test wiring~~ — DONE (web-test + python-test jobs, dotnet already runs tests)
32. ~~Stale test fixes~~ — DONE (middleware tests updated for API-C1/H1 fix, suggestions test for LAM-H4)
33. ~~API-M3~~ — DONE (dispatcher ownership check on line items)
34. ~~API-M4~~ — DONE (dispatcher ownership check on PDF endpoints)
35. ~~API-M6~~ — DONE (25MB file size cap on presigned uploads)
36. ~~API-M8~~ — DONE (explicit transaction on bulk update)
37. ~~API-M11~~ — DONE (LogError on audit catch blocks)
38. ~~API-M14~~ — DONE (dev signing key from user-secrets/env, not config)
39. ~~WEB-M2~~ — DONE (Redux logout on 401)
40. ~~WEB-M5~~ — DONE (typed CreateProposalRequest with ServiceCategory/Priority)
41. ~~WEB-M6~~ — DONE (PDF-only, 25MB max, toast on failure)
42. ~~WEB-M7~~ — DONE (error Alert with retry in AdminWorkspace)
43. ~~WEB-M10~~ — DONE (canApprove/canSend/canRevise state guards)
44. ~~WEB-M13~~ — DONE (ToastContainer inside ErrorBoundary)
45. ~~LAM-M1~~ — DONE (event/record validation in all SQS handlers)
46. ~~LAM-M5~~ — DONE (logger.exception in all except blocks)
47. ~~LAM-M8~~ — DONE (S3 key sanitization with _validate_s3_key)
48. ~~INF-M5~~ — DONE (enforceSSL on all 4 S3 buckets)
49. ~~INF-M8~~ — DONE (SHA-pinned workflow refs in all 3 workflow files)
### Phase 6 — Remaining Medium Fixes ✅ COMPLETE
**API hardening:**
50. ~~API-M2~~ — DONE (startup throws if auth not configured)
51. ~~API-M5~~ — DONE (FluentValidation for VendorProposal, GeneratedPdf, SimilarReference)
52. ~~API-M7~~ — DONE (AsNoTracking on read-only queries)
53. ~~API-M9~~ — DONE (stderr logged, not returned to client)
54. ~~API-M10~~ — DONE (generic auth error responses)
55. ~~API-M12~~ — DONE (before/after JSON in audit trail)
56. ~~API-M13~~ — DONE (previous role logged on change)
**Web DX and reliability:**
57. ~~WEB-M3~~ — DONE (10-char min on scope field)
58. ~~WEB-M4~~ — DONE (ServiceCategory Other aligned across all layers)
59. ~~WEB-M8~~ — DONE (dashboard error state instead of zeros)
60. ~~WEB-M9~~ — DONE (loading skeleton for line items)
61. ~~WEB-M11~~ — DONE (Return to Review button on approved proposals)
**Lambda reliability:**
62. ~~LAM-M2~~ — DONE (prompt injection sanitizer)
63. ~~LAM-M3~~ — DONE (50MB PDF size check)
64. ~~LAM-M6~~ — DONE (numeric validation on suggestions)
65. ~~LAM-M9~~ — DONE (5-minute TTL on API key cache)
**Infra tightening:**
66. ~~INF-M1~~ — DONE (Bedrock IAM scoped to specific inference profile ARN)
67. ~~INF-M2~~ — DONE (AOSS data access policy scoped per principal)
68. ~~INF-M9~~ — DONE (`--require-approval broadening` in deploy script)
### Phase 7 — Remaining (not yet started)
- WEB-H1: Token refresh mechanism (requires backend refresh token flow)
- Mobile High findings (MOB-H1 through H4): separate release cycle
- Remaining Medium findings: API-M1, WEB-M1/M12, LAM-M4/M7/M10-M14, INF-M3-M4/M6-M7
- QA-C6: Mobile test coverage
---
## Positive Findings
- RDS: private subnets, not publicly accessible, encrypted, deletion protection, 7-day backups
- Cognito: self-signup disabled (admin-created accounts only)
- CORS: properly scoped to production origin
- Secrets: production connection string uses Secrets Manager
- S3: all buckets have `BlockPublicAccess.BLOCK_ALL`
- CloudFront: OAC, HTTPS redirect, security headers, TLS 1.2 minimum
- GitHub Actions: OIDC (no long-lived credentials), minimal permissions
- Monitoring: alarms for DLQ depth, RDS metrics, Lambda errors, API 5xx
- Mobile: tokens in iOS Keychain, no secrets in Fastlane config
- SQS: visibility timeout properly sized for Lambda consumers

77
CLAUDE.md Normal file
View file

@ -0,0 +1,77 @@
# Proposal System - Claude Code Project Memory
## Project Overview
Proposal management platform for Sea Haven Industries. Dispatchers submit service requests, AI generates draft line items via Bedrock RAG, admins review/approve in a pricing workspace, system produces branded PDFs.
## Architecture
- **api/**: .NET 8 API, EF Core, PostgreSQL, Cognito JWT auth
- **web/**: React 19 + MUI v7 SPA, Vite, CloudFront + S3
- **mobile/**: React Native 0.85 iOS app, offline-capable, Hermes
- **lambdas/**: Python 3.12 Lambdas (ARM64): pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator
- **infra/**: CDK TypeScript (foundation-stack, compute-stack, frontend-stack)
- **shared/**: Shared TypeScript API contracts
- **scripts/**: Local dev helpers
## Auth Model
External: Cognito JWT via API Gateway (web + mobile client IDs, groups: dispatchers/admins/sysadmins)
Internal: Lambdas → .NET Function URL with Secrets Manager API key via custom middleware
## Request Flow
1. Dispatcher submits proposal (web/mobile) → InReview (no Draft stage)
2. Bedrock RAG suggests line items from pricing library
3. Admin reviews in workspace, edits line items, approves
4. PDF generation queued via SQS → Python Lambda → branded PDF → S3
5. State machine: InReview → Approved → Sent → Revised
## Infrastructure
AWS us-east-1, RDS PostgreSQL 15, S3, SQS+DLQ, Cognito+Google OAuth, OpenSearch Serverless, Bedrock KB, GitHub Actions OIDC, CloudFront+S3 OAC
## Agent Delegation Rules
### For audit and hardening work, use the Explore-Plan-Execute pipeline:
1. Spawn specialist subagents for parallel investigation (api-security, web-audit, mobile-audit, lambda-pipeline, infra-cicd, qa-testing)
2. Consolidate findings into AUDIT-REPORT.md before implementing
3. Prioritize: Critical > High > Medium > Low
4. Implement fixes in logical phases, commit after each phase
5. Use separate git worktrees/branches for parallel implementation where safe
### Working Rules
- Never commit secrets, credentials, .env files, or local artifacts
- If secrets found in code: document, remove safely, ensure proper config mechanism
- Preserve existing business logic unless broken, insecure, or contradicted
- Run lint/typecheck/build/test after each phase
- If context reaches 65%, pause, commit, update AUDIT-REPORT.md with HANDOFF ADDENDUM
### Severity Levels
- **Critical**: security/data exposure/auth bypass/data corruption
- **High**: broken core workflow, deployment blocker, missing authz, invalid infra
- **Medium**: reliability, validation, logging, test gaps
- **Low**: cleanup, DX, docs, polish
## Audit Status
AUDIT-REPORT.md completed 2026-05-27. 5 Critical, 36 High, 75+ Medium, 60+ Low findings. Phase 1-5 complete: all Critical/High fixed, 17 Medium fixed, CI runs 108 tests.
### Critical Findings (fix first)
- **API-C1**: InternalApiKeyMiddleware applies globally, bypasses JWT on any route
- **API-C2**: JWT signature validation skipped when Authority is empty
- **WEB-C1**: JWT stored in localStorage (XSS token theft)
- **LAM-C1 / INF-H1**: Function URL authType NONE, publicly accessible
- **QA-C1**: Zero test coverage, no test projects, CI runs no tests
### Remediation Conventions
- Reference finding IDs (API-C1, WEB-H3, LAM-H4, etc.) in commit messages and code comments
- Format: `// Fix: API-C1 — scope internal key to /internal/ paths`
- Update AUDIT-REPORT.md after each phase: mark fixed findings, note deferred items
- Parallel-safe worktree splits: api/ changes, web/ changes, and infra/ changes don't conflict
- Verify after each phase: `dotnet build` (api), `npx tsc --noEmit` (web), `npx cdk synth` (infra)

View file

@ -6,7 +6,7 @@ Internal proposal management platform for Sea Haven Industries. Dispatchers subm
Monorepo with five primary services:
- **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL for internal access
- **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL (AWS_IAM) for internal access
- **React 19 Web** -- MUI v7 admin/dispatcher workspace served via CloudFront + S3
- **React Native Mobile** -- iOS-first field app for dispatchers (offline-capable)
- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, AOSS index provisioning
@ -36,7 +36,7 @@ proposal-system/
| Mobile | React Native CLI 0.85, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue |
| Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 |
| Infrastructure | CDK TypeScript (aws-cdk-lib 2.253.1) |
| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), OpenSearch Serverless, Claude via Bedrock Runtime |
| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), OpenSearch Serverless (VPC-only), Claude Sonnet via Bedrock cross-region inference |
| Auth | Cognito User Pool + Google OAuth IdP (groups: dispatchers, admins, sysadmins) |
## AWS Resources
@ -46,13 +46,13 @@ All resources are in **us-east-1** (account 328440206208).
| CDK Stack | Key Resources |
|---|---|
| `proposal-system-foundation` | RDS PostgreSQL 15 (t4g.small), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager |
| `proposal-system-compute` | API Gateway HTTP API (JWT authorizer), .NET 8 API Lambda + Function URL, Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection, Bedrock KB |
| `proposal-system-compute` | API Gateway HTTP API (JWT authorizer + access logging), .NET 8 API Lambda + Function URL (AWS_IAM), Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection (VPC endpoint), Bedrock KB |
| `proposal-system-frontend` | CloudFront distribution (S3 OAC) |
| Resource Type | Names |
|---|---|
| S3 Buckets | `proposal-system-uploads`, `proposal-system-generated`, `proposal-system-library`, `seahaven-ios-certificates` |
| SQS | `proposal-system-jobs` (720s visibility, reportBatchItemFailures) + `proposal-system-jobs-dlq` (message body filtering by jobType) |
| SQS | `proposal-system-jobs` (720s visibility, SQS-managed encryption, reportBatchItemFailures) + `proposal-system-jobs-dlq` (SQS-managed encryption, message body filtering by jobType) |
| Secrets | `proposal-system/db-credentials`, `proposal-system/internal-api-key` |
## Local Development
@ -108,14 +108,16 @@ npx cdk synth
### CI (on pull request to main)
Five parallel jobs calling org reusable workflows:
Seven parallel jobs calling org reusable workflows:
| Job | Workflow | What it checks |
|---|---|---|
| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution |
| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (104 xUnit tests) |
| Web Frontend Check | `ci-typescript-cdk.yaml` | TypeScript typecheck for web |
| Web Tests | `ci-typescript-cdk.yaml` | vitest suite (26 tests — auth, interceptors, components) |
| Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile |
| Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ |
| Python Tests | `ci-python-sam.yaml` | pytest suite (19 tests — pdf-generate, suggestions handlers) |
| CDK Synth | `ci-typescript-cdk.yaml` | Synthesize CDK stacks (includes .NET publish) |
### Deploy (on push to main)
@ -158,11 +160,11 @@ Two-layer auth architecture with defense-in-depth:
| External clients → API Gateway `/{proxy+}` | Cognito JWT authorizer (web + mobile client IDs) | .NET JWT middleware (ValidateAudience=true) |
| `/api/health` | None (public) | None |
| `/api/auth/callback`, `/api/auth/dev-login` | None (unauthenticated) | None (pre-auth endpoints) |
| Internal Lambdas → Function URL | None (NONE auth type) | Internal API key (`X-Internal-Api-Key` header, value from Secrets Manager) |
| Internal Lambdas → Function URL | AWS_IAM (grantInvokeUrl) | Internal API key (`X-Internal-Api-Key` header, value from Secrets Manager) |
**Role-based access:** Cognito groups (`dispatchers`, `admins`, `sysadmins`) map to API roles via `cognito:groups` claim. Dispatchers can only see their own proposals (ownership enforced in service layer). VendorProposals and GeneratedPdfs endpoints restricted to admins/sysadmins.
**Internal API key:** Python Lambdas call the .NET API via a Lambda Function URL (bypasses API Gateway JWT check). The `InternalApiKeyMiddleware` validates the key and assigns the `admins` role to the synthetic identity.
**Internal API key:** Python Lambdas call the .NET API via a Lambda Function URL with AWS_IAM auth (bypasses API Gateway JWT check). The `InternalApiKeyMiddleware` validates the `X-Internal-Api-Key` header and assigns the `admins` role to the synthetic identity. Lambdas cache the API key from Secrets Manager with a 5-minute TTL.
## Data Flow
@ -175,3 +177,31 @@ Two-layer auth architecture with defense-in-depth:
7. On send: `library-ingest` Lambda adds approved proposal to KB for future matching
Failed SQS messages are reported via `batchItemFailures` and retried up to 3 times before moving to the DLQ.
## Testing
149 tests across three stacks, all run in CI on every PR:
| Suite | Framework | Count | Coverage |
|---|---|---|---|
| .NET API | xUnit | 104 | State machine transitions, authorization attributes, middleware, validators, ProposalNumberGenerator, LineItemService state guards |
| Web | vitest | 26 | ProtectedRoute, RoleGuard, API client interceptor (401 logout, token attachment) |
| Python Lambdas | pytest | 19 | pdf-generate and suggestions handler contracts |
```bash
cd api && dotnet test # .NET tests
cd web && npm test # vitest
cd lambdas && python -m pytest # pytest
```
## Security
Hardening applied across all layers (see AUDIT-REPORT.md for full details):
- **Auth:** Cognito JWT validation with audience check, startup fails if auth not configured, DevMode gated to `IsDevelopment()`
- **API:** FluentValidation on all DTOs, generic error responses (no stack traces or config leaks), structured audit logging with before/after diffs
- **Function URL:** AWS_IAM auth + internal API key (two-layer defense)
- **Infrastructure:** S3 `enforceSSL` + `BLOCK_ALL`, SQS managed encryption, OpenSearch VPC-only, Cognito optional TOTP MFA, API Gateway access logging
- **Lambdas:** Prompt injection sanitization, PDF size limits, numeric validation on AI suggestions, S3 key sanitization, idempotent SQS processing
- **CI/CD:** OIDC (no long-lived credentials), SHA-pinned workflow refs, `--require-approval broadening` on local deploys
- **Web:** sessionStorage for tokens (not localStorage), error boundaries, role guards on all admin routes, 401 interceptor clears auth state

View file

@ -1,228 +0,0 @@
# Proposal System — Retrospective (2026-05-19, updated 2026-05-20, session 5 added 2026-05-20)
## Executive Summary
Across four sessions (2026-05-18, 2026-05-19, and two on 2026-05-20), the project progressed from a broken mobile CI pipeline to a functional mobile app on device AND a fully tested web frontend with working dev-mode authentication, proposal lifecycle, and admin workflows.
**Sessions 1-3 (Mobile):** The mobile app went from a broken CI pipeline to a functional app running on a physical device with working email/password authentication. Three distinct launch crashes were resolved, Cognito SRP login was validated end-to-end, and multiple UI issues were fixed. The app boots, authenticates, and renders on iOS 26 hardware. However, it cannot communicate with the backend API from a device, Google OAuth crashes the app, and the branch has not been merged to main.
**Session 4 (Web):** Full local web testing exposed six bugs in the API and frontend: enum serialization failures, identity/role confusion in dev-login, incorrect proposal ownership filtering, Autocomplete binding issues, audit log format errors on Postgres jsonb columns, and missing API idempotency. All were fixed in four logical commits. The web app's core workflow — submit as dispatcher, review/edit/approve/send as admin — is now functional end-to-end in dev mode.
**Session 5 (Automated QA):** Ran 4 parallel test agents covering ~145 test cases across every API endpoint and every frontend page. Found 18 bugs (2 critical, 4 high, 7 medium, 5 low). All 16 actionable bugs fixed in 4 commits. Critical: admin dashboard LINQ crash (EF Core can't translate TimeSpan.TotalHours to SQL) and revision endpoint 500 (unique constraint on ProposalNumber). High: dispatcher dashboard data exposure (missing mine filter), no frontend role guards on admin routes, submittedByName null on mutation responses, invalid role silently defaulting to Admin. Also extracted duplicated STATUS_COLORS and format utilities into shared modules, wired the admin dashboard filter dropdowns, and added debounce to customer search.
**Systemic findings:** The web session revealed two architectural gaps: (1) audit logging was fragile — a format error in a non-critical audit write could roll back an otherwise successful save, and (2) state machine transitions lacked idempotency, meaning retries or UI double-clicks could produce 500 errors instead of graceful no-ops. Both are patterns that would have surfaced in production under real load. Session 5 added a third: the frontend had no authorization enforcement — `ProtectedRoute` checked authentication but not role, so any logged-in user could navigate to admin pages by URL.
## Standards Compliance Status
| Rule | Status | Detail |
|------|--------|--------|
| Naming conventions | PASS | kebab-case throughout, branch name follows pattern |
| CI/CD pipeline exists | PASS | `deploy-mobile.yaml` and `deploy.yaml` both trigger on push to main |
| OIDC deploy role | PASS | `githubdeploy-proposal-system` |
| README accurate | **PARTIAL** | Root README updated (0.85, deploy status). `mobile/README.md` incomplete (no auth/device docs). Web dev mode not documented. |
| Confluence updated | **FAIL** | No Atlassian MCP. Architecture Map missing mobile pipeline, Cognito auth flow, and web dev-mode setup |
| Memory updated | **UPDATED** | Project memory updated with session 4 web fixes. New feedback memories created for API patterns. |
| Git workflow | PASS | All sessions used feature branch `mobile/fix-react-version-and-ui` |
| Commit messages | PASS | Imperative mood, explains "why", logically grouped changes |
| CDK callback URL fix | PASS | Fixed in CDK + live Cognito via AWS CLI |
| Pre-push lint/typecheck | NOT VERIFIED | Did not run typecheck before pushing — should have per CLAUDE.md hook |
| Dev secrets excluded | PASS | `appsettings.Development.json` (dev signing key) kept untracked, not committed |
| API idempotency | **FIXED** | Approve, MarkSent, Revise transitions now idempotent. Audit failures isolated from saves. |
## Required Memory Updates
### Completed this session
1. **`project_proposal_system.md`** — Updated with session 4 web fixes: dev-mode setup, enum serialization, audit log format, idempotent transitions, scoped My Proposals filtering.
2. **`feedback_mobile_deploy_lessons.md`** — All three session-3 lessons added (React pinning, import type, dev API URL). Done in session 3.
3. **`feedback_react_version_pinning.md`** — Created in session 3. Done.
4. **`feedback_api_idempotency.md`** — NEW: State machine transitions must be idempotent. Audit writes must not roll back successful saves.
5. **`feedback_jsonb_audit_format.md`** — NEW: Postgres jsonb columns require valid JSON, not plain strings. Audit details must be wrapped.
### Still outstanding
6. **`reference_mobile_testflight.md`** — The ⚠️ note about "username/password flow needs to be added" is now resolved but not yet updated in the file.
## Required Documentation Updates
| Doc | Status | Action |
|-----|--------|--------|
| Root `README.md` | Updated (session 2) | Needs update: add web dev-mode setup instructions (DevMode, dev-login, local Postgres) |
| `mobile/README.md` | Exists but incomplete | Add: email/password auth via Cognito SRP, `patch-package` for netinfo iOS 26 fix, React version pinning requirement, local device testing setup |
| `api/` dev setup | **MISSING** | No documentation for local API development: `appsettings.Development.json` template (without secrets), Docker Compose for Postgres, dev-login endpoint usage |
| Confluence "AWS Architecture Map" | **OUTSTANDING** | Still blocked — no Atlassian MCP. Needs: mobile CI/CD pipeline, Cognito auth flow, web dev-mode architecture |
| CDK `foundation-stack.ts` | Updated (session 2) | Callback URLs fixed, CfnOutputs added for client IDs |
## Reusable Skills / Automations
| Candidate | Type | ROI | Description |
|-----------|------|-----|-------------|
| React version coherence check | CI step | **CRITICAL** | `node -e` script that reads `node_modules/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js`, extracts the hardcoded version string, and compares against `node_modules/react/package.json`. Fails if mismatch. Would have caught the exact crash from session 3. |
| API idempotency test suite | Integration test | **HIGH** | For each state-machine endpoint (approve, markSent, revise), call twice with same input and assert both return 200 with matching response. Would have caught all three idempotency bugs from session 4. Pattern: assert `f(f(x)) == f(x)` for all mutation endpoints. |
| Audit isolation pattern | Code pattern | **HIGH** | Wrap all non-critical audit writes in try/catch so they never roll back the primary operation. Consider a `SafeAuditService` decorator or middleware. Session 4's bulk update 500 error was caused by audit failure after a successful save. |
| iOS device smoke test script | Script / Runbook | HIGH | Checklist for post-build device testing: connect device, Metro `--host <LAN_IP>`, build with automatic signing, verify login, test auth flow. |
| `patch-package` audit CI step | CI check | MEDIUM | Verify patches in `mobile/patches/` still apply cleanly and patched packages haven't been updated. |
| Dev-mode login test harness | Script | MEDIUM | Script that exercises all three dev-login roles (SysAdmin, Admin, Dispatcher) and verifies each returns a distinct user identity with correct role. Would have caught the role/identity confusion bugs immediately. |
| Cognito ID token user extraction | Utility | LOW | `parseUserFromIdToken()` in `auth.ts` — reusable for any Cognito-backed app. |
## Key Lessons Learned
### React Native Runtime (Sessions 1-3)
1. **React version MUST be pinned exactly, not with semver range.** RN 0.85.3's bundled `ReactNativeRenderer-dev.js` has a hard check: `if ("19.2.3" !== isomorphicReactPackageVersion)`. The peer dependency says `^19.2.3`, npm resolves to 19.2.6, and the app crashes with an opaque "Cannot read property 'default' of undefined" in `getPaperRenderer`. Pin `"react": "19.2.3"` in package.json.
2. **`import type` is not reliably erased for modules with native initialization.** `import type { CognitoUserSession } from 'amazon-cognito-identity-js'` was NOT stripped by Babel in RN's build pipeline. The module eagerly initialized native crypto at import time, causing a crash. Fix: remove the import entirely and use `any`, or use dynamic `await import()`.
3. **`localhost` in dev config is the phone, not the Mac.** `API_URL: 'http://localhost:5000/api'` in dev mode is unreachable from a physical device. Need either LAN IP or a fallback strategy.
### iOS 26 Specific (Sessions 1-3)
4. **CoreTelephony APIs removed without replacement.** `@react-native-community/netinfo` v12.0.1 still calls deprecated APIs. Required `patch-package` with `respondsToSelector:` guards.
5. **iPhone Mirroring is the fastest way to test on device.** Built into macOS 26, gives full touch control. Developer Mode on the phone is under Settings > Privacy & Security.
### .NET API / Web Frontend (Session 4)
6. **Postgres jsonb columns reject plain strings.** The `details` column on `AuditLogs` is typed `jsonb`. Writing a bare string like `"Added: Widget repair"` produces Postgres error 22P02. Wrap in a JSON object: `JsonSerializer.Serialize(new { message = details })`. This is easy to miss because SQLite and SQL Server `nvarchar` accept anything.
7. **System.Text.Json requires explicit `JsonStringEnumConverter` for enum round-tripping.** Without it, sending `"ServiceCategory": "Plumbing"` from the frontend produces a validation error because the default deserializer expects an integer. Must add `options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())` in `AddJsonOptions`.
8. **State machine transitions must be idempotent.** Approve, MarkSent, and Revise all threw `InvalidOperationException` on repeat calls (e.g., from network retries or UI double-clicks). Fix: if already in the target state, return current entity instead of throwing. This is especially critical for mobile clients with unreliable connectivity.
9. **Audit writes must never roll back successful business operations.** `BulkUpdateAsync` saved line items successfully, then `_audit.LogAsync` threw (due to the jsonb format bug), and the entire request returned 500. The user saw "unexpected error" even though their data was saved. Fix: wrap non-critical audit calls in try/catch.
10. **Dev-login must produce deterministic, distinct identities per role.** Using `Guid.NewGuid()` for CognitoSub meant the same email produced different identities across logins. Using a single hardcoded email for all roles meant switching roles didn't actually switch users. Fix: deterministic sub (`dev-{email}`), distinct email/name per role, and update role on existing user if changed.
11. **"My Proposals" means ownership, not role-based filtering.** Initial implementation filtered by role (show all for admins, filter for dispatchers). The correct behavior: "My Proposals" always shows only proposals the current user submitted, regardless of role. Admins see all proposals in the separate Admin Queue.
### Process (All Sessions)
12. **Feature branch for iterative debugging works.** Session 2 pushed 10+ commits to main. Sessions 3-4 used `mobile/fix-react-version-and-ui` — all fixes stay off main until ready.
13. **User testing catches what type systems and linters can't.** Session 4's six bugs all passed TypeScript compilation and would pass unit tests. They were logic errors in business rules, serialization config, and identity management that only surfaced through manual workflow testing. Interactive testing with role-switching is essential before any deploy.
## Highest ROI Improvements
Ranked by impact-to-effort:
1. **Add API idempotency integration tests** (1 hr) — For each state-machine endpoint, call twice with same input and assert both return 200. Pattern: `assert f(f(x)) == f(x)`. Would have caught 3 of session 4's bugs automatically. Generalizable to any future endpoint.
2. **Add React version coherence CI check** (30 min) — A 10-line node script that extracts the expected version from the bundled renderer and compares to installed React. Prevents the most time-consuming crash from session 3.
3. **Isolate audit writes from business operations** (30 min) — Create a `SafeAuditService` wrapper or add try/catch to all audit calls in services. The pattern already exists in `LineItemService` but should be systematic, not ad-hoc. A single audit format bug caused a 500 on an otherwise successful operation.
4. **Add `.gitignore` to API project** (5 min) — `appsettings.Development.json` contains dev signing keys and must not be committed. Currently relying on manual exclusion. Add it to `.gitignore` with a template file (`.example`) that documents the required keys without values.
5. **Document web dev-mode setup** (15 min) — No docs exist for running the API locally: Docker Compose for Postgres, `appsettings.Development.json` template, dev-login endpoint, role switching. This will block any new developer.
6. **Merge `mobile/fix-react-version-and-ui` and deploy** (5 min) — Branch has 8 commits of critical fixes (sessions 3-4). Current TestFlight build crashes. Must merge before next submission.
7. **Fix dev API_URL for physical devices** (10 min) — `localhost:5000` is unreachable from iPhone. Blocks all API-dependent mobile features during device testing.
8. **Pin all RN ecosystem versions exactly** (5 min) — Already done for React; extend to all `@react-native/*` packages.
## Outstanding Risks or Follow-Ups
| Priority | Item | Risk | Branch/Location |
|----------|------|------|-----------------|
| **BLOCKING** | Feature branch not merged — TestFlight build still crashes | Any TestFlight tester or Apple reviewer will see a crash | `mobile/fix-react-version-and-ui` |
| **BLOCKING** | Google OAuth crashes the app on tap | Apple reviewer may try both login methods | `auth.ts` → `react-native-app-auth` → Cognito Hosted UI |
| **HIGH** | `appsettings.Development.json` not in `.gitignore` | Dev signing key could be accidentally committed | `api/src/ProposalSystem.Api/` |
| **HIGH** | Dev API_URL is `localhost:5000` — all API calls fail on device | Proposals can't be created, viewed, or searched on device | `config.ts` |
| **HIGH** | Placeholder app icons (solid blue squares) | Unprofessional for TestFlight / App Store | `ios/ProposalSystem/Images.xcassets` |
| **HIGH** | No web dev-mode setup documentation | New developer can't run the system locally | Root README / api/ docs |
| ~~HIGH~~ | ~~Audit isolation is ad-hoc, not systematic~~ | ~~Fixed session 4; session 5 verified via testing~~ | ~~LineItemService, ProposalService~~ |
| **MEDIUM** | Confluence Architecture Map still missing mobile pipeline | Documentation debt per CLAUDE.md | Page 1540098 |
| **MEDIUM** | `react-native-paper` has known issues with RN 0.85 | May surface as bugs in production | GitHub issues #4889, #4905 |
| **MEDIUM** | netinfo patch needs monitoring for upstream fix | Patches can silently break on version bumps | `patches/@react-native-community+netinfo+12.0.1.patch` |
| **MEDIUM** | `DeleteAsync` in `LineItemService` doesn't update `TotalBidAmount` | Deleting a line item leaves the proposal total stale | `LineItemService.cs:113` |
| **LOW** | `no-floating-promises` ESLint rule still not added | Class of crash from session 2 can recur | `mobile/.eslintrc` |
| **LOW** | Cognito test user password in memory file | Acceptable for internal test account | `reference_mobile_testflight.md` |
| **LOW** | 4 Dependabot vulnerabilities open | Adam deferred these | GitHub Security tab |
## Session 5 Changelog — Automated QA & Bug Fixes (2026-05-20)
All fixes on `mobile/fix-react-version-and-ui` (4 commits, not yet on main):
### `d00c552` Fix admin dashboard LINQ crash, revise unique constraint, and mutation response data
- **`AdminController.cs`** — Rewrote avgTurnaround query to fetch approved times to memory before computing TotalHours (EF Core/Npgsql cannot translate TimeSpan.TotalHours)
- **`ProposalService.cs` ReviseAsync** — Append `-R{n}` suffix to revision ProposalNumber to avoid unique index violation
- **`ProposalService.cs` Update/Approve/MarkSent** — Added `.Include(p => p.SubmittedBy)` so mutation responses return submittedByName
### `8666010` Validate dev-login input: reject empty email and invalid role
- **`AuthController.cs`** — Return 400 for empty/whitespace email and invalid role strings; default role changed from Admin to Dispatcher (least privilege)
### `4d72b63` Add frontend role guards, fix dashboard data exposure, and harden UX
- **`ProtectedRoute.tsx`** — New `RoleGuard` component for role-based route protection
- **`App.tsx`** — Wrapped admin routes with `RoleGuard`; `/admin/*` requires Admin/SysAdmin, `/admin/users` requires SysAdmin
- **`Dashboard.tsx`** — Added `mine: true` to dashboard query so dispatchers only see their own proposals
- **`AdminWorkspace.tsx`** — Auto-save dirty changes before approving (was silently discarding edits)
- **`ProposalFormPage.tsx`** — Added onError toast handler; added 300ms debounce on customer autocomplete search
- **`admin.ts`** — Stopped swallowing errors in getPdf; fixed AuditEntry.details type to `string | null`
- **`LoginPage.tsx`** — Fixed pre-existing TS error with noUncheckedIndexedAccess
### `5e89e42` Extract shared constants and format utils, wire admin dashboard filters
- **`constants/index.ts`** — Added shared `STATUS_COLORS` and `PRIORITY_COLORS` (removed from 5 files)
- **`lib/format.ts`** — New shared `formatCurrency`, `formatDate`, `formatDateTime` (removed from 4 files)
- **`AdminDashboard.tsx`** — Wired Category and Priority filter dropdowns to `usePaginatedList` extraParams
- **`ProposalDetailPage.tsx`** — Added 'Revised' to STATUS_ORDER so stepper renders correctly
### QA Coverage Summary
| Test Area | Tests | Pass | Fail | Agent |
|-----------|-------|------|------|-------|
| Auth & RBAC | 35 | 31 | 4 | Auth agent |
| Proposal CRUD & State Machine | 38 | 35 | 3 | Proposal agent |
| Line Items, Customers & Misc | 42 | 39 | 3 | Misc agent |
| Web Frontend Code Review + API | ~30 | ~25 | ~5 | Frontend agent |
| **Total** | **~145** | **~130** | **~15** | — |
## Session 3 Changelog — Mobile Device Testing (2026-05-20)
Fixes on `mobile/fix-react-version-and-ui` (not yet on main):
- **Pin React 19.2.3** — fixes renderer version mismatch crash
- **Remove `import type` from cognito-auth.ts** — fixes eager module init crash
- **Auth fallback to ID token** — `loginWithCredentials` parses user from JWT when backend API unreachable
- **Safe area fixes** — Settings gets top+bottom edges; Dashboard/AdminDashboard use bottom-only (nav header handles top)
- **Pull-to-refresh separation** — filter chip taps no longer trigger refresh animation on proposal queue
- **Welcome name** — shows first name or email prefix instead of full email
- **Service category chips** — wrapping `Chip` components replace truncated `SegmentedButtons`
- **ErrorBoundary** — added to App root for crash visibility
Already on main (sessions 2-3):
- **Email/password login screen** — TextInput form + Cognito SRP via `amazon-cognito-identity-js`
- **Cognito config populated** — real values from AWS CLI
- **CDK callback URL fix** — `com.seahavenind.proposals://auth/callback`
- **netinfo iOS 26 patch** — `patch-package` with `respondsToSelector:` guards
- **Auto-deploy enabled** — `deploy-mobile.yaml` triggers on `mobile/**` push to main
- **Root README updated** — RN 0.85, deploy status corrected
- **`mobile/README.md` created** — local dev, signing, CI/CD docs
## Session 4 Changelog — Web Local Testing (2026-05-20)
All fixes on `mobile/fix-react-version-and-ui` (4 commits, not yet on main):
### `f44caba` Fix JSON enum serialization and audit log jsonb format
- **`Program.cs`** — Added `JsonStringEnumConverter` to `AddJsonOptions` so frontend string enums deserialize correctly
- **`AuditService.cs`** — Wrapped plain-string audit details in `JsonSerializer.Serialize(new { message = details })` for Postgres jsonb column
- **`global.json`** — Relaxed SDK version from 8.0.400 to 8.0.100 to match installed .NET SDK
### `f37c2aa` Fix dev-login role switching, distinct users, and user resolution races
- **`AuthController.cs`** — Dev-login now updates role on existing user; CognitoSub is deterministic (`dev-{email}`)
- **`CurrentUserService.cs`** — Added `DbUpdateException` catch on concurrent user creation with retry lookup
- **`LoginPage.tsx`** — Distinct dev user per role (SysAdmin=Adam, Admin=Sarah, Dispatcher=Mike)
### `9b97b7b` Fix proposal workflow: scoped My Proposals, idempotent state transitions
- **`ProposalService.cs`** — New proposals created as `InReview` (not `Draft`); My Proposals filters by `Mine` parameter (not role); `ApproveAsync`, `MarkSentAsync`, `ReviseAsync` all idempotent
- **`LineItemService.cs`** — Audit writes wrapped in try/catch so failures don't roll back successful saves
- **`ProposalDtos.cs`** — Added `bool Mine` filter parameter
- **`proposals.ts` / `ProposalListPage.tsx`** — Frontend passes `mine: true` for My Proposals page
### `2645d97` Fix customer name not binding from Autocomplete free text input
- **`ProposalFormPage.tsx`** — `onInputChange` with `reason === 'input'` now calls `handleChange('customerName', value)` alongside search

View file

@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Application"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Infrastructure", "src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455559999}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Tests", "tests\ProposalSystem.Tests\ProposalSystem.Tests.csproj", "{A1B2C3D4-1111-2222-3333-44445555AAAA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -33,5 +35,9 @@ Global
{A1B2C3D4-1111-2222-3333-444455559999}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -22,20 +22,25 @@ public class AdminController : ControllerBase
[HttpGet("dashboard")]
public async Task<ActionResult<DashboardResponse>> GetDashboard(CancellationToken ct)
{
// Fix: API-M7 — AsNoTracking on read-only dashboard queries
var pendingCount = await _db.Proposals
.AsNoTracking()
.CountAsync(p => p.Status == ProposalStatus.InReview, ct);
var weekStart = DateTime.UtcNow.AddDays(-7);
var approvedThisWeek = await _db.Proposals
.AsNoTracking()
.CountAsync(p => p.ApprovedAt >= weekStart, ct);
var approvedCount = await _db.Proposals
.AsNoTracking()
.CountAsync(p => p.ApprovedAt.HasValue, ct);
double avgTurnaround = 0;
if (approvedCount > 0)
{
var recentApproved = await _db.Proposals
.AsNoTracking()
.Where(p => p.ApprovedAt.HasValue)
.OrderByDescending(p => p.ApprovedAt)
.Take(200)
@ -44,7 +49,7 @@ public class AdminController : ControllerBase
avgTurnaround = recentApproved.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours);
}
var totalProposals = await _db.Proposals.CountAsync(ct);
var totalProposals = await _db.Proposals.AsNoTracking().CountAsync(ct);
return Ok(new DashboardResponse(pendingCount, approvedThisWeek, avgTurnaround, totalProposals));
}

View file

@ -20,22 +20,37 @@ public class AuthController : ControllerBase
private readonly ProposalDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _config;
private readonly ILogger<AuthController> _logger;
public AuthController(ProposalDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config)
public AuthController(ProposalDbContext db, IHttpClientFactory httpClientFactory, IConfiguration config, ILogger<AuthController> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
[HttpPost("callback")]
public async Task<ActionResult<AuthResponse>> Callback([FromBody] AuthCallbackRequest request, CancellationToken ct)
{
// Fix: API-H2 — validate redirectUri against allowlist to prevent open-redirect attacks
var allowedRedirectUris = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"https://proposals.seahaven.com/callback",
"https://d2yevct5e5uuz5.cloudfront.net/callback",
};
if (_config.GetValue<bool>("Auth:DevMode"))
allowedRedirectUris.Add("http://localhost:5173/callback");
if (!allowedRedirectUris.Contains(request.RedirectUri))
return BadRequest(new { message = "Invalid redirect URI" });
var domain = _config["Auth:CognitoDomain"];
var clientId = _config["Auth:ClientId"];
// Fix: API-M10 — return generic error to avoid leaking internal auth configuration details
if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(clientId))
return StatusCode(500, new { message = "Auth not configured" });
return StatusCode(500, new { message = "Authentication service unavailable" });
var tokenResponse = await ExchangeCodeAsync(domain, clientId, request.Code, request.RedirectUri, ct);
if (tokenResponse == null)
@ -44,33 +59,29 @@ public class AuthController : ControllerBase
var handler = new JwtSecurityTokenHandler();
var authority = _config["Auth:Authority"];
JwtSecurityToken idToken;
if (!string.IsNullOrEmpty(authority))
{
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(ct);
// Fix: API-M10 — return generic error to avoid leaking internal auth configuration details
if (string.IsNullOrEmpty(authority))
return StatusCode(500, new { message = "Authentication service unavailable" });
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidcConfig.SigningKeys,
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
};
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(ct);
handler.ValidateToken(tokenResponse.IdToken, validationParams, out var validatedToken);
idToken = (JwtSecurityToken)validatedToken;
}
else
var validationParams = new TokenValidationParameters
{
idToken = handler.ReadJwtToken(tokenResponse.IdToken);
}
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidcConfig.SigningKeys,
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
};
handler.ValidateToken(tokenResponse.IdToken, validationParams, out var validatedToken);
var idToken = (JwtSecurityToken)validatedToken;
var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? throw new InvalidOperationException("No sub claim in ID token");
@ -106,7 +117,14 @@ public class AuthController : ControllerBase
var changed = false;
if (user.Email != email) { user.Email = email; changed = true; }
if (user.DisplayName != name) { user.DisplayName = name; changed = true; }
if (user.Role != role) { user.Role = role; changed = true; }
if (user.Role != role)
{
// Fix: API-M13 — log previous role on Cognito-synced role changes
_logger.LogInformation("User {Email} role changed from {OldRole} to {NewRole} via Cognito sync",
user.Email, user.Role, role);
user.Role = role;
changed = true;
}
if (changed)
{
user.UpdatedAt = DateTime.UtcNow;
@ -119,7 +137,7 @@ public class AuthController : ControllerBase
user.Email,
user.DisplayName,
user.Role.ToString(),
tokenResponse.AccessToken
tokenResponse.IdToken
));
}
@ -131,8 +149,9 @@ public class AuthController : ControllerBase
return NotFound();
var signingKey = _config["Auth:DevSigningKey"];
// Fix: API-M10 — return generic error to avoid leaking dev auth configuration details
if (string.IsNullOrEmpty(signingKey))
return StatusCode(500, new { message = "Dev signing key not configured" });
return StatusCode(500, new { message = "Authentication service unavailable" });
if (string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new { message = "Email is required" });

View file

@ -1,7 +1,9 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
@ -19,28 +21,39 @@ public class FilesController : ControllerBase
private readonly IJobPublisher _jobPublisher;
private readonly IAuditService _audit;
private readonly IConfiguration _config;
private readonly ILogger<FilesController> _logger;
public FilesController(
ProposalDbContext db,
IS3Service s3,
IJobPublisher jobPublisher,
IAuditService audit,
IConfiguration config)
IConfiguration config,
ILogger<FilesController> logger)
{
_db = db;
_s3 = s3;
_jobPublisher = jobPublisher;
_audit = audit;
_config = config;
_logger = logger;
}
// Fix: API-M6 — max file size for presigned upload URLs (25 MB)
private const long MaxFileSizeBytes = 25 * 1024 * 1024;
[HttpPost("attachments")]
public async Task<ActionResult<PresignedUploadResponse>> UploadAttachment(
Guid proposalId,
[FromQuery] string fileName,
[FromQuery] string? vendorName,
[FromQuery] long? fileSize,
CancellationToken ct)
{
// Fix: API-M6 — reject uploads exceeding 25 MB
if (fileSize.HasValue && fileSize.Value > MaxFileSizeBytes)
return BadRequest(new { error = $"File size exceeds maximum allowed size of {MaxFileSizeBytes / (1024 * 1024)} MB" });
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
if (proposal == null) return NotFound();
@ -94,40 +107,140 @@ public class FilesController : ControllerBase
}
[HttpGet("pdf")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, CancellationToken ct)
public async Task<ActionResult<PdfDownloadResponse>> GetPdf(Guid proposalId, [FromQuery] bool regenerate = false, CancellationToken ct = default)
{
var pdf = await _db.GeneratedPdfs
var devMode = _config.GetValue<bool>("Auth:DevMode");
var pdf = regenerate ? null : await _db.GeneratedPdfs
.Where(p => p.ProposalId == proposalId)
.OrderByDescending(p => p.Revision)
.FirstOrDefaultAsync(ct);
if (pdf == null)
if (pdf != null && devMode)
{
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
return Accepted(new { message = "PDF generation queued" });
var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key);
if (System.IO.File.Exists(localPath))
{
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct);
return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key));
}
}
var bucket = _config["GENERATED_BUCKET"]!;
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
if (pdf != null && !devMode)
{
var bucket = _config["GENERATED_BUCKET"]!;
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct);
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
}
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {pdf.Revision}", ct);
if (devMode)
{
return await _generatePdfLocally(proposalId, ct);
}
return Ok(new PdfDownloadResponse(url, DateTime.UtcNow.AddMinutes(60)));
await _jobPublisher.PublishAsync("pdf-generate", new { proposalId }, ct);
return Accepted(new { message = "PDF generation queued" });
}
private async Task<ActionResult> _generatePdfLocally(Guid proposalId, CancellationToken ct)
{
var outputDir = _getGeneratedPdfsDir();
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", ".."));
var scriptPath = Path.Combine(repoRoot, "scripts", "generate-pdf-local.py");
var psi = new ProcessStartInfo
{
FileName = "python3",
Arguments = $"\"{scriptPath}\" {proposalId} \"{outputDir}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = Process.Start(psi)!;
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
var stderr = await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
if (process.ExitCode != 0)
{
// Fix: API-M9 — log stderr instead of returning it to the client
_logger.LogError("Dev PDF generation failed for proposal {ProposalId} (exit code {ExitCode}): {Stderr}",
proposalId, process.ExitCode, stderr);
return StatusCode(500, new { message = "PDF generation failed" });
}
var result = JsonSerializer.Deserialize<JsonElement>(stdout.Trim());
var filePath = result.GetProperty("path").GetString()!;
var s3Key = result.GetProperty("s3Key").GetString()!;
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
if (proposal == null) return NotFound();
var generatedPdf = new GeneratedPdf
{
Id = Guid.NewGuid(),
ProposalId = proposalId,
Revision = proposal.CurrentRevision,
S3Key = s3Key,
GeneratedAt = DateTime.UtcNow,
GeneratedById = Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value),
};
_db.GeneratedPdfs.Add(generatedPdf);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.GeneratePDF, proposalId, null, ct);
return PhysicalFile(filePath, "application/pdf", Path.GetFileName(filePath));
}
private string _getGeneratedPdfsDir()
{
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", ".."));
return Path.Combine(repoRoot, "generated-pdfs");
}
[HttpGet("pdf/versions")]
public async Task<ActionResult<IReadOnlyList<PdfVersionResponse>>> GetPdfVersions(
Guid proposalId,
CancellationToken ct)
{
// Fix: API-M7 — AsNoTracking on read-only query
var pdfs = await _db.GeneratedPdfs
.AsNoTracking()
.Where(p => p.ProposalId == proposalId)
.OrderByDescending(p => p.Revision)
.Select(p => new PdfVersionResponse(p.Revision, p.GeneratedAt))
.ToListAsync(ct);
return Ok(pdfs);
}
[HttpGet("pdf/{revision:int}")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<PdfDownloadResponse>> GetPdfRevision(
Guid proposalId,
int revision,
CancellationToken ct)
{
var devMode = _config.GetValue<bool>("Auth:DevMode");
var pdf = await _db.GeneratedPdfs
.FirstOrDefaultAsync(p => p.ProposalId == proposalId && p.Revision == revision, ct);
if (pdf == null) return NotFound();
if (devMode)
{
var localPath = Path.Combine(_getGeneratedPdfsDir(), pdf.S3Key);
if (System.IO.File.Exists(localPath))
{
await _audit.LogAsync(AuditAction.Download, proposalId, $"Downloaded rev {revision}", ct);
return PhysicalFile(localPath, "application/pdf", Path.GetFileName(pdf.S3Key));
}
return NotFound();
}
var bucket = _config["GENERATED_BUCKET"]!;
var url = await _s3.GeneratePresignedDownloadUrlAsync(bucket, pdf.S3Key, 60);
@ -142,7 +255,9 @@ public class FilesController : ControllerBase
Guid proposalId,
CancellationToken ct)
{
// Fix: API-M7 — AsNoTracking on read-only query
var entities = await _db.VendorProposals
.AsNoTracking()
.Where(v => v.ProposalId == proposalId)
.OrderByDescending(v => v.UploadedAt)
.ToListAsync(ct);

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
@ -9,7 +10,7 @@ namespace ProposalSystem.Api.Controllers;
[ApiController]
[Route("api/generated-pdfs")]
[Authorize(Roles = "admins,sysadmins")]
[Authorize]
public class GeneratedPdfsController : ControllerBase
{
private readonly ProposalDbContext _db;
@ -22,11 +23,16 @@ public class GeneratedPdfsController : ControllerBase
}
[HttpPost]
[Authorize(Roles = "admins,sysadmins")]
public async Task<IActionResult> Create([FromBody] CreateGeneratedPdfRequest request, CancellationToken ct)
{
var proposal = await _db.Proposals.FindAsync(new object[] { request.ProposalId }, ct);
if (proposal == null) return NotFound();
// Fix: API-M4 — verify dispatcher ownership before allowing PDF creation
if (!AuthorizeProposalAccess(proposal))
return Forbid();
await _currentUser.ResolveAsync();
var pdf = new GeneratedPdf
@ -44,6 +50,13 @@ public class GeneratedPdfsController : ControllerBase
return Created($"/api/generated-pdfs/{pdf.Id}", new { pdf.Id, pdf.S3Key, pdf.Revision });
}
}
public record CreateGeneratedPdfRequest(Guid ProposalId, string S3Key);
// Fix: API-M4 — dispatchers can only access PDFs for proposals they submitted
private bool AuthorizeProposalAccess(Proposal proposal)
{
if (_currentUser.Role != UserRole.Dispatcher)
return true;
return proposal.SubmittedById == _currentUser.UserId;
}
}

View file

@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Api.Controllers;
@ -11,10 +13,17 @@ namespace ProposalSystem.Api.Controllers;
public class LineItemsController : ControllerBase
{
private readonly ILineItemService _lineItemService;
private readonly ICurrentUserService _currentUser;
private readonly ProposalDbContext _db;
public LineItemsController(ILineItemService lineItemService)
public LineItemsController(
ILineItemService lineItemService,
ICurrentUserService currentUser,
ProposalDbContext db)
{
_lineItemService = lineItemService;
_currentUser = currentUser;
_db = db;
}
[HttpGet]
@ -22,6 +31,10 @@ public class LineItemsController : ControllerBase
Guid proposalId,
CancellationToken ct)
{
// Fix: API-M3 — dispatchers can only read line items for their own proposals
if (!await AuthorizeProposalAccessAsync(proposalId, ct))
return Forbid();
var result = await _lineItemService.GetByProposalIdAsync(proposalId, ct);
return Ok(result);
}
@ -58,4 +71,14 @@ public class LineItemsController : ControllerBase
await _lineItemService.DeleteAsync(proposalId, itemId, ct);
return NoContent();
}
// Fix: API-M3 — verify dispatchers only access their own proposals' line items
private async Task<bool> AuthorizeProposalAccessAsync(Guid proposalId, CancellationToken ct)
{
if (_currentUser.Role != UserRole.Dispatcher)
return true;
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
return proposal != null && proposal.SubmittedById == _currentUser.UserId;
}
}

View file

@ -25,6 +25,8 @@ public class ProposalsController : ControllerBase
}
[HttpPost]
[ProducesResponseType(typeof(ProposalResponse), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<ProposalResponse>> Create(
[FromBody] CreateProposalRequest request,
CancellationToken ct)
@ -34,6 +36,7 @@ public class ProposalsController : ControllerBase
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ProposalListResponse>), 200)]
public async Task<ActionResult<PagedResponse<ProposalListResponse>>> GetAll(
[FromQuery] ProposalFilterRequest filter,
CancellationToken ct)
@ -43,6 +46,8 @@ public class ProposalsController : ControllerBase
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ProposalResponse), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<ProposalResponse>> GetById(Guid id, CancellationToken ct)
{
var result = await _proposalService.GetByIdAsync(id, ct);
@ -52,6 +57,9 @@ public class ProposalsController : ControllerBase
[HttpPut("{id:guid}")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(ProposalResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<ProposalResponse>> Update(
Guid id,
[FromBody] UpdateProposalRequest request,
@ -63,12 +71,23 @@ public class ProposalsController : ControllerBase
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = "admins,sysadmins")]
[ProducesResponseType(typeof(ProposalResponse), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<ProposalResponse>> Approve(Guid id, CancellationToken ct)
{
var result = await _proposalService.ApproveAsync(id, ct);
return Ok(result);
}
[HttpPost("{id:guid}/return-to-review")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> ReturnToReview(Guid id, CancellationToken ct)
{
var result = await _proposalService.ReturnToReviewAsync(id, ct);
return Ok(result);
}
[HttpPost("{id:guid}/send")]
[Authorize(Roles = "admins,sysadmins")]
public async Task<ActionResult<ProposalResponse>> MarkSent(Guid id, CancellationToken ct)

View file

@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class SitesController : ControllerBase
{
private readonly ISiteService _siteService;
public SitesController(ISiteService siteService)
{
_siteService = siteService;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SiteResponse>>> Search(
[FromQuery] string? query,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
return Ok(Array.Empty<SiteResponse>());
var results = await _siteService.SearchAsync(query, ct);
return Ok(results);
}
}

View file

@ -27,7 +27,9 @@ public class UsersController : ControllerBase
[HttpGet("me")]
public async Task<ActionResult<UserProfileResponse>> GetMe(CancellationToken ct)
{
// Fix: API-M7 — AsNoTracking on read-only query
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == _currentUser.UserId, ct);
if (user == null) return NotFound();
@ -39,7 +41,9 @@ public class UsersController : ControllerBase
[Authorize(Roles = "sysadmins")]
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken ct)
{
// Fix: API-M7 — AsNoTracking on read-only query
var users = await _db.Users
.AsNoTracking()
.OrderBy(u => u.DisplayName)
.Select(u => new UserResponse(u.Id, u.Email, u.DisplayName, u.Role, u.IsActive, u.CreatedAt))
.ToListAsync(ct);
@ -54,11 +58,18 @@ public class UsersController : ControllerBase
var user = await _db.Users.FindAsync(new object[] { id }, ct);
if (user == null) return NotFound();
// Fix: API-M13 — log previous role alongside new role
var previousRole = user.Role;
user.Role = request.Role;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.UpdateRole, null, $"User {user.Email} role changed to {request.Role}", ct);
var auditDetails = System.Text.Json.JsonSerializer.Serialize(new
{
email = user.Email,
role = new { old = previousRole.ToString(), @new = request.Role.ToString() }
});
await _audit.LogAsync(AuditAction.UpdateRole, null, auditDetails, ct);
return NoContent();
}

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
@ -63,12 +64,3 @@ public class VendorProposalsController : ControllerBase
return NoContent();
}
}
public record UpdateVendorProposalRequest(
string? VendorName,
string? ExtractedData,
decimal? TotalVendorCost,
string? ProcessingStatus
);
public record UpdateStatusRequest(string ProcessingStatus);

View file

@ -45,7 +45,7 @@ public class GlobalExceptionHandler : IMiddleware
{
Status = 404,
Title = "Not Found",
Detail = exception.Message,
Detail = "The requested resource was not found",
}
),
UnauthorizedAccessException => (
@ -63,7 +63,7 @@ public class GlobalExceptionHandler : IMiddleware
{
Status = 400,
Title = "Invalid Operation",
Detail = exception.Message,
Detail = "The requested operation is not valid for the current state",
}
),
_ => (
@ -77,10 +77,8 @@ public class GlobalExceptionHandler : IMiddleware
),
};
if (statusCode == HttpStatusCode.InternalServerError)
{
_logger.LogError(exception, "Unhandled exception");
}
_logger.LogError(exception, "Exception on {Method} {Path}: {Status}",
context.Request.Method, context.Request.Path, (int)statusCode);
context.Response.StatusCode = (int)statusCode;
context.Response.ContentType = "application/problem+json";

View file

@ -6,6 +6,14 @@ namespace ProposalSystem.Api.Middleware;
public class InternalApiKeyMiddleware
{
private static readonly string[] AllowedPathPrefixes =
[
"/api/proposals",
"/api/vendor-proposals",
"/api/generated-pdfs",
"/api/files",
];
private readonly RequestDelegate _next;
private readonly byte[] _apiKeyBytes;
private readonly ILogger<InternalApiKeyMiddleware> _logger;
@ -25,26 +33,35 @@ public class InternalApiKeyMiddleware
!string.IsNullOrEmpty(providedKey.ToString()))
{
var providedBytes = Encoding.UTF8.GetBytes(providedKey.ToString());
if (CryptographicOperations.FixedTimeEquals(providedBytes, _apiKeyBytes))
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "system"),
new Claim("sub", "system-lambda-caller"),
new Claim(ClaimTypes.Email, "system@proposal-system.internal"),
new Claim("email", "system@proposal-system.internal"),
new Claim("name", "System"),
new Claim(ClaimTypes.Role, "admins"),
new Claim("cognito:groups", "admins"),
};
var identity = new ClaimsIdentity(claims, "InternalApiKey");
context.User = new ClaimsPrincipal(identity);
}
else
if (!CryptographicOperations.FixedTimeEquals(providedBytes, _apiKeyBytes))
{
_logger.LogWarning("Invalid internal API key from {RemoteIp} on {Path}",
context.Connection.RemoteIpAddress, context.Request.Path);
context.Response.StatusCode = 401;
return;
}
var path = context.Request.Path.Value ?? "";
if (!AllowedPathPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogWarning("Internal API key used on disallowed path {Path} from {RemoteIp}",
context.Request.Path, context.Connection.RemoteIpAddress);
context.Response.StatusCode = 403;
return;
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "system"),
new Claim("sub", "system-lambda-caller"),
new Claim(ClaimTypes.Email, "system@proposal-system.internal"),
new Claim("email", "system@proposal-system.internal"),
new Claim("name", "System"),
new Claim(ClaimTypes.Role, "admins"),
new Claim("cognito:groups", "admins"),
};
var identity = new ClaimsIdentity(claims, "InternalApiKey");
context.User = new ClaimsPrincipal(identity);
}
await _next(context);

View file

@ -1,4 +1,5 @@
using System.Text;
using Amazon.DynamoDBv2;
using Amazon.S3;
using Amazon.SecretsManager;
using Amazon.SQS;
@ -6,6 +7,7 @@ using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ProposalSystem.Api.Middleware;
using ProposalSystem.Api.Services;
using ProposalSystem.Application.Interfaces;
@ -16,7 +18,7 @@ using ProposalSystem.Infrastructure.Services;
var builder = WebApplication.CreateBuilder(args);
// Dev mode flag (read early for conditional setup)
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode");
var devMode = builder.Configuration.GetValue<bool>("Auth:DevMode") && builder.Environment.IsDevelopment();
// AWS SDK clients (skip in dev mode — no real AWS credentials needed)
if (!devMode)
@ -25,6 +27,7 @@ if (!devMode)
builder.Services.AddAWSService<IAmazonS3>();
builder.Services.AddAWSService<IAmazonSQS>();
builder.Services.AddAWSService<IAmazonSecretsManager>();
builder.Services.AddAWSService<IAmazonDynamoDB>();
}
// Database
@ -78,7 +81,14 @@ if (!string.IsNullOrEmpty(cognitoAuthority))
}
else if (devMode)
{
var devSigningKey = builder.Configuration["Auth:DevSigningKey"]!;
// Fix: API-M14 — dev signing key must come from user-secrets or environment variables,
// never from committed config files. Set via: dotnet user-secrets set "Auth:DevSigningKey" "<value>"
var devSigningKey = builder.Configuration["Auth:DevSigningKey"];
if (string.IsNullOrEmpty(devSigningKey))
throw new InvalidOperationException(
"Auth:DevSigningKey is required when DevMode is enabled. " +
"Set it via user-secrets or environment variables, not in committed config files.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
@ -97,8 +107,9 @@ else if (devMode)
}
else
{
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
// Fix: API-M2 — fail loud on missing auth config; app must not silently run unauthenticated
throw new InvalidOperationException(
"Authentication is not configured. Set Auth:Authority for Cognito or Auth:DevMode=true (Development only).");
}
builder.Services.AddAuthorization();
@ -112,9 +123,15 @@ builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IProposalNumberGenerator, ProposalNumberGenerator>();
if (devMode)
{
builder.Services.AddScoped<IS3Service, DevS3Service>();
builder.Services.AddScoped<ISiteService, DevSiteService>();
}
else
{
builder.Services.AddScoped<IS3Service, S3Service>();
builder.Services.AddScoped<ISiteService, SiteService>();
}
builder.Services.AddScoped<ISimilarProposalService, SimilarProposalService>();
var jobsQueueUrl = builder.Configuration["JOBS_QUEUE_URL"] ?? "";
if (string.IsNullOrEmpty(jobsQueueUrl))
@ -145,6 +162,37 @@ builder.Services.AddControllers(options =>
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// OpenAPI / Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Proposal System API",
Version = "v1",
Description = "Sea Haven Industries proposal management API",
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Cognito JWT access token",
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" },
},
Array.Empty<string>()
},
});
});
// Middleware
builder.Services.AddTransient<GlobalExceptionHandler>();
@ -157,7 +205,7 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = new List<string> { "https://proposals.seahaven.com" };
var origins = new List<string> { "https://proposals.seahaven.com", "https://d2yevct5e5uuz5.cloudfront.net" };
if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
@ -172,6 +220,10 @@ builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
var app = builder.Build();
app.UseMiddleware<GlobalExceptionHandler>();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Proposal System API v1"));
app.UseCors();
app.UseMiddleware<InternalApiKeyMiddleware>();
app.UseAuthentication();
@ -188,4 +240,10 @@ app.Use(async (context, next) =>
app.MapControllers();
app.MapHealthChecks("/api/health");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ProposalDbContext>();
db.Database.Migrate();
}
app.Run();

View file

@ -10,6 +10,10 @@
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<ItemGroup>
<Content Update="Data\verified-sites.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ProposalSystem.Application\ProposalSystem.Application.csproj" />
<ProjectReference Include="..\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
@ -22,6 +26,7 @@
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.27" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,42 @@
using System.Text.Json;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Api.Services;
public class DevSiteService : ISiteService
{
private static readonly Lazy<List<SiteResponse>> _sites = new(LoadSites);
public Task<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default)
{
var upper = query.ToUpper();
var results = _sites.Value
.Where(s => s.SiteCode.Contains(upper))
.Take(20)
.ToList();
return Task.FromResult<IReadOnlyList<SiteResponse>>(results);
}
private static List<SiteResponse> LoadSites()
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "verified-sites.json");
if (!File.Exists(path))
return new List<SiteResponse>();
var json = File.ReadAllText(path);
var items = JsonSerializer.Deserialize<List<JsonElement>>(json) ?? new();
return items
.Select(item => new SiteResponse(
item.TryGetProperty("siteCode", out var sc) ? sc.GetString() ?? "" : "",
item.TryGetProperty("fullAddress", out var fa) ? fa.GetString() : null,
item.TryGetProperty("address", out var a) ? a.GetString() : null,
item.TryGetProperty("city", out var c) ? c.GetString() : null,
item.TryGetProperty("state", out var s) ? s.GetString() : null,
item.TryGetProperty("zip", out var z) ? z.GetString() : null
))
.OrderBy(s => s.SiteCode)
.ToList();
}
}

View file

@ -12,6 +12,11 @@ public record PdfDownloadResponse(
DateTime ExpiresAt
);
public record PdfVersionResponse(
int Revision,
DateTime GeneratedAt
);
public record VendorProposalResponse(
Guid Id,
string VendorName,
@ -20,3 +25,15 @@ public record VendorProposalResponse(
string ProcessingStatus,
object? ExtractedData
);
// Fix: API-M5 — moved request DTOs here from controllers so validators can reference them
public record UpdateVendorProposalRequest(
string? VendorName,
string? ExtractedData,
decimal? TotalVendorCost,
string? ProcessingStatus
);
public record UpdateStatusRequest(string ProcessingStatus);
public record CreateGeneratedPdfRequest(Guid ProposalId, string S3Key);

View file

@ -4,6 +4,7 @@ namespace ProposalSystem.Application.DTOs;
public record CreateProposalRequest(
string WorkOrderNumber,
string? PoNumber,
string CustomerName,
string CustomerAddress,
string ScopeOfWork,
@ -15,14 +16,16 @@ public record CreateProposalRequest(
public record UpdateProposalRequest(
string? RefinedScope,
string? Notes,
Guid? AssignedAdminId,
ProposalStatus? Status
string? PoNumber,
string? WorkOrderNumber,
Guid? AssignedAdminId
);
public record ProposalResponse(
Guid Id,
string ProposalNumber,
string WorkOrderNumber,
string? PoNumber,
string CustomerName,
string CustomerAddress,
string ScopeOfWork,

View file

@ -0,0 +1,10 @@
namespace ProposalSystem.Application.DTOs;
public record SiteResponse(
string SiteCode,
string? FullAddress,
string? Address,
string? City,
string? State,
string? Zip
);

View file

@ -9,6 +9,7 @@ public interface IProposalService
Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default);
Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default);
Task<ProposalResponse> ApproveAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default);
Task<ProposalResponse> ReviseAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ProposalResponse>> GetRevisionHistoryAsync(Guid id, CancellationToken ct = default);

View file

@ -0,0 +1,8 @@
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Interfaces;
public interface ISiteService
{
Task<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default);
}

View file

@ -0,0 +1,18 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
// Fix: API-M5 — add FluentValidation for GeneratedPdf DTO
public class CreateGeneratedPdfValidator : AbstractValidator<CreateGeneratedPdfRequest>
{
public CreateGeneratedPdfValidator()
{
RuleFor(x => x.ProposalId)
.NotEmpty().WithMessage("Proposal ID is required");
RuleFor(x => x.S3Key)
.NotEmpty().WithMessage("S3 key is required")
.MaximumLength(1000);
}
}

View file

@ -0,0 +1,19 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
// Fix: API-M5 — add FluentValidation for SimilarReference DTO
public class CreateSimilarReferenceValidator : AbstractValidator<CreateSimilarReferenceRequest>
{
public CreateSimilarReferenceValidator()
{
RuleFor(x => x.ReferencedLibraryItemId)
.NotEmpty().WithMessage("Referenced library item ID is required")
.MaximumLength(500);
RuleFor(x => x.SimilarityScore)
.InclusiveBetween(0f, 1f)
.WithMessage("Similarity score must be between 0 and 1");
}
}

View file

@ -0,0 +1,15 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
namespace ProposalSystem.Application.Validators;
public class UpdateProposalValidator : AbstractValidator<UpdateProposalRequest>
{
public UpdateProposalValidator()
{
RuleFor(x => x.RefinedScope).MaximumLength(10000);
RuleFor(x => x.Notes).MaximumLength(5000);
RuleFor(x => x.PoNumber).MaximumLength(50);
RuleFor(x => x.WorkOrderNumber).MaximumLength(50);
}
}

View file

@ -0,0 +1,41 @@
using FluentValidation;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Domain.Entities;
namespace ProposalSystem.Application.Validators;
// Fix: API-M5 — add FluentValidation for VendorProposal DTOs
public class UpdateVendorProposalValidator : AbstractValidator<UpdateVendorProposalRequest>
{
public UpdateVendorProposalValidator()
{
RuleFor(x => x.VendorName)
.MaximumLength(200)
.When(x => x.VendorName != null);
RuleFor(x => x.ExtractedData)
.MaximumLength(100000)
.When(x => x.ExtractedData != null);
RuleFor(x => x.TotalVendorCost)
.GreaterThanOrEqualTo(0)
.When(x => x.TotalVendorCost.HasValue)
.WithMessage("Total vendor cost cannot be negative");
RuleFor(x => x.ProcessingStatus)
.Must(s => Enum.TryParse<ProcessingStatus>(s, out _))
.When(x => x.ProcessingStatus != null)
.WithMessage("Invalid processing status");
}
}
public class UpdateStatusRequestValidator : AbstractValidator<UpdateStatusRequest>
{
public UpdateStatusRequestValidator()
{
RuleFor(x => x.ProcessingStatus)
.NotEmpty().WithMessage("Processing status is required")
.Must(s => Enum.TryParse<ProcessingStatus>(s, out _))
.WithMessage("Invalid processing status");
}
}

View file

@ -14,7 +14,8 @@ public enum AuditAction
GeneratePDF,
MarkSent,
CreateRevision,
UpdateRole
UpdateRole,
ReturnToReview
}
public class AuditLog

View file

@ -15,7 +15,8 @@ public enum ServiceCategory
Plumbing,
Electrical,
General,
Renovation
Renovation,
Other
}
public enum Priority
@ -30,6 +31,7 @@ public class Proposal
public Guid Id { get; set; }
public string ProposalNumber { get; set; } = string.Empty;
public string WorkOrderNumber { get; set; } = string.Empty;
public string? PoNumber { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CustomerAddress { get; set; } = string.Empty;
public string ScopeOfWork { get; set; } = string.Empty;

View file

@ -0,0 +1,507 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using ProposalSystem.Infrastructure.Data;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
[DbContext(typeof(ProposalDbContext))]
[Migration("20260522000000_AddPoNumber")]
partial class AddPoNumber
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Details")
.HasColumnType("jsonb");
b.Property<string>("IpAddress")
.HasColumnType("text");
b.Property<Guid?>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("Timestamp");
b.HasIndex("UserId");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Addresses")
.HasColumnType("jsonb");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Customers");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("GeneratedById")
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GeneratedById");
b.HasIndex("ProposalId");
b.ToTable("GeneratedPdfs");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PricingMode")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("UnitPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("LineItems");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ApprovedById")
.HasColumnType("uuid");
b.Property<Guid?>("AssignedAdminId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentRevision")
.HasColumnType("integer");
b.Property<string>("CustomerAddress")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProposalNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefinedScope")
.HasColumnType("text");
b.Property<string>("ScopeOfWork")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceCategory")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubmittedById")
.HasColumnType("uuid");
b.Property<decimal>("TotalBidAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("VendorTotalCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<string>("WorkOrderNumber")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ApprovedById");
b.HasIndex("AssignedAdminId");
b.HasIndex("ParentProposalId");
b.HasIndex("ProposalNumber")
.IsUnique();
b.HasIndex("SubmittedById");
b.ToTable("Proposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<DateTime>("ReferencedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ReferencedById")
.HasColumnType("uuid");
b.Property<string>("ReferencedLibraryItemId")
.IsRequired()
.HasColumnType("text");
b.Property<float>("SimilarityScore")
.HasColumnType("real");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.HasIndex("ReferencedById");
b.ToTable("SimilarProposalReferences");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CognitoSub")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CognitoSub")
.IsUnique();
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ExtractedData")
.HasColumnType("jsonb");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ProcessingStatus")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<string>("S3Key")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("TotalVendorCost")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VendorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ProposalId");
b.ToTable("VendorProposals");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.AuditLog", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId");
b.HasOne("ProposalSystem.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("User");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.GeneratedPdf", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "GeneratedBy")
.WithMany()
.HasForeignKey("GeneratedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("GeneratedBy");
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.LineItem", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("LineItems")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.User", "ApprovedBy")
.WithMany()
.HasForeignKey("ApprovedById")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "AssignedAdmin")
.WithMany()
.HasForeignKey("AssignedAdminId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "ParentProposal")
.WithMany()
.HasForeignKey("ParentProposalId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ProposalSystem.Domain.Entities.User", "SubmittedBy")
.WithMany()
.HasForeignKey("SubmittedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ApprovedBy");
b.Navigation("AssignedAdmin");
b.Navigation("ParentProposal");
b.Navigation("SubmittedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.SimilarProposalReference", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany()
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ProposalSystem.Domain.Entities.User", "ReferencedBy")
.WithMany()
.HasForeignKey("ReferencedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
b.Navigation("ReferencedBy");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.VendorProposal", b =>
{
b.HasOne("ProposalSystem.Domain.Entities.Proposal", "Proposal")
.WithMany("VendorProposals")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("ProposalSystem.Domain.Entities.Proposal", b =>
{
b.Navigation("LineItems");
b.Navigation("VendorProposals");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProposalSystem.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPoNumber : Migration
{
private static readonly Guid AmazonCustomerId = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000001");
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PoNumber",
table: "Proposals",
type: "text",
nullable: true);
migrationBuilder.InsertData(
table: "Customers",
columns: new[] { "Id", "Name", "Addresses", "CreatedAt", "UpdatedAt" },
values: new object[]
{
AmazonCustomerId,
"Amazon Services, LLC",
null,
new DateTime(2026, 5, 22, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 22, 0, 0, 0, DateTimeKind.Utc),
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Customers",
keyColumn: "Id",
keyValue: AmazonCustomerId);
migrationBuilder.DropColumn(
name: "PoNumber",
table: "Proposals");
}
}
}

View file

@ -202,6 +202,9 @@ namespace ProposalSystem.Infrastructure.Data.Migrations
b.Property<Guid?>("ParentProposalId")
.HasColumnType("uuid");
b.Property<string>("PoNumber")
.HasColumnType("text");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");

View file

@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.DynamoDBv2" Version="3.7.400" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />

View file

@ -18,9 +18,18 @@ public class AuditService : IAuditService
public async Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default)
{
var jsonDetails = details != null
? JsonSerializer.Serialize(new { message = details })
: null;
// Fix: API-M12 — accept pre-serialized JSON from callers that provide structured audit data.
// If the details string is already valid JSON (starts with '{'), use it directly;
// otherwise, wrap plain text in a JSON envelope for consistency.
string? jsonDetails = null;
if (details != null)
{
var trimmed = details.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
jsonDetails = details;
else
jsonDetails = JsonSerializer.Serialize(new { message = details });
}
var entry = new AuditLog
{

View file

@ -1,4 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
@ -6,20 +8,25 @@ using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Infrastructure.Services;
// Fix: API-H6 — add structured logging for line item operations
public class LineItemService : ILineItemService
{
private readonly ProposalDbContext _db;
private readonly IAuditService _audit;
private readonly ILogger<LineItemService> _logger;
public LineItemService(ProposalDbContext db, IAuditService audit)
public LineItemService(ProposalDbContext db, IAuditService audit, ILogger<LineItemService> logger)
{
_db = db;
_audit = audit;
_logger = logger;
}
public async Task<IReadOnlyList<LineItemResponse>> GetByProposalIdAsync(Guid proposalId, CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only query
return await _db.LineItems
.AsNoTracking()
.Where(li => li.ProposalId == proposalId)
.OrderBy(li => li.SortOrder)
.Select(li => MapToResponse(li))
@ -32,7 +39,10 @@ public class LineItemService : ILineItemService
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
{
_logger.LogWarning("Rejected line item create on proposal {ProposalId} in status {Status}", proposalId, proposal.Status);
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
}
var now = DateTime.UtcNow;
var lineItem = new LineItem
@ -54,11 +64,22 @@ public class LineItemService : ILineItemService
_db.LineItems.Add(lineItem);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("Line item {LineItemId} created on proposal {ProposalId}", lineItem.Id, proposalId);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
// Fix: API-M12 — capture line item details in audit trail
var addAuditDetails = JsonSerializer.Serialize(new
{
action = "add",
lineItem = new { description = request.Description, quantity = request.Quantity, unit = request.Unit, totalPrice = request.TotalPrice }
});
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, addAuditDetails, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write audit log for line item creation on proposal {ProposalId}", proposalId);
}
catch { /* audit failure should not roll back a successful save */ }
return MapToResponse(lineItem);
}
@ -69,45 +90,68 @@ public class LineItemService : ILineItemService
?? throw new KeyNotFoundException($"Proposal {proposalId} not found");
if (proposal.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
var existing = await _db.LineItems
.Where(li => li.ProposalId == proposalId)
.ToListAsync(ct);
_db.LineItems.RemoveRange(existing);
var now = DateTime.UtcNow;
var newItems = request.LineItems.Select(entry => new LineItem
{
Id = entry.Id ?? Guid.NewGuid(),
ProposalId = proposalId,
Description = entry.Description,
Quantity = entry.Quantity,
Unit = entry.Unit,
UnitPrice = entry.UnitPrice,
TotalPrice = entry.TotalPrice,
PricingMode = entry.PricingMode,
SortOrder = entry.SortOrder,
Source = entry.Source,
CreatedAt = now,
UpdatedAt = now,
}).ToList();
_db.LineItems.AddRange(newItems);
proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice);
proposal.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
_logger.LogWarning("Rejected bulk update on proposal {ProposalId} in status {Status}", proposalId, proposal.Status);
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
}
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
}
catch { /* audit failure should not roll back a successful save */ }
var existing = await _db.LineItems
.Where(li => li.ProposalId == proposalId)
.ToListAsync(ct);
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
_db.LineItems.RemoveRange(existing);
var now = DateTime.UtcNow;
var newItems = request.LineItems.Select(entry => new LineItem
{
Id = entry.Id ?? Guid.NewGuid(),
ProposalId = proposalId,
Description = entry.Description,
Quantity = entry.Quantity,
Unit = entry.Unit,
UnitPrice = entry.UnitPrice,
TotalPrice = entry.TotalPrice,
PricingMode = entry.PricingMode,
SortOrder = entry.SortOrder,
Source = entry.Source,
CreatedAt = now,
UpdatedAt = now,
}).ToList();
_db.LineItems.AddRange(newItems);
proposal.TotalBidAmount = newItems.Sum(li => li.TotalPrice);
proposal.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
try
{
// Fix: API-M12 — capture before/after item counts in audit trail
var bulkAuditDetails = JsonSerializer.Serialize(new
{
action = "bulkUpdate",
itemCount = new { old = existing.Count, @new = newItems.Count }
});
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, bulkAuditDetails, ct);
}
catch (Exception ex)
{
// Fix: API-M11 — log audit failures instead of silently swallowing
_logger.LogError(ex, "Failed to write audit log for bulk update on proposal {ProposalId}", proposalId);
}
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}
public async Task DeleteAsync(Guid proposalId, Guid lineItemId, CancellationToken ct = default)
@ -118,12 +162,23 @@ public class LineItemService : ILineItemService
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct)!;
if (proposal!.Status == ProposalStatus.Approved || proposal.Status == ProposalStatus.Sent)
{
_logger.LogWarning("Rejected line item delete on proposal {ProposalId} in status {Status}", proposalId, proposal.Status);
throw new InvalidOperationException("Cannot modify line items on approved/sent proposals");
}
_db.LineItems.Remove(lineItem);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Removed: {lineItem.Description}", ct);
_logger.LogInformation("Line item {LineItemId} deleted from proposal {ProposalId}", lineItemId, proposalId);
// Fix: API-M12 — capture deleted line item details in audit trail
var deleteAuditDetails = JsonSerializer.Serialize(new
{
action = "delete",
lineItem = new { id = lineItemId, description = lineItem.Description, quantity = lineItem.Quantity, unit = lineItem.Unit, totalPrice = lineItem.TotalPrice }
});
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, deleteAuditDetails, ct);
}
private static LineItemResponse MapToResponse(LineItem li) => new(

View file

@ -1,4 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
@ -6,6 +8,7 @@ using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Infrastructure.Services;
// Fix: API-H6 — add structured logging for state transitions and errors
public class ProposalService : IProposalService
{
private readonly ProposalDbContext _db;
@ -13,19 +16,22 @@ public class ProposalService : IProposalService
private readonly IProposalNumberGenerator _numberGenerator;
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly ILogger<ProposalService> _logger;
public ProposalService(
ProposalDbContext db,
ICurrentUserService currentUser,
IProposalNumberGenerator numberGenerator,
IAuditService audit,
IJobPublisher jobPublisher)
IJobPublisher jobPublisher,
ILogger<ProposalService> logger)
{
_db = db;
_currentUser = currentUser;
_numberGenerator = numberGenerator;
_audit = audit;
_jobPublisher = jobPublisher;
_logger = logger;
}
public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default)
@ -40,6 +46,7 @@ public class ProposalService : IProposalService
Id = Guid.NewGuid(),
ProposalNumber = proposalNumber,
WorkOrderNumber = request.WorkOrderNumber,
PoNumber = request.PoNumber,
CustomerName = request.CustomerName,
CustomerAddress = request.CustomerAddress,
ScopeOfWork = request.ScopeOfWork,
@ -57,24 +64,35 @@ public class ProposalService : IProposalService
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
_logger.LogInformation("Proposal {ProposalId} created with number {ProposalNumber} by user {UserId}",
proposal.Id, proposalNumber, _currentUser.UserId);
try
{
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
}
catch { }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write audit log for proposal submission {ProposalId}", proposal.Id);
}
try
{
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
}
catch { }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish suggestions job for proposal {ProposalId}", proposal.Id);
}
return MapToResponse(proposal);
}
public async Task<ProposalResponse?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only query
var proposal = await _db.Proposals
.AsNoTracking()
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.Include(p => p.ApprovedBy)
@ -91,7 +109,9 @@ public class ProposalService : IProposalService
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
// Fix: API-M7 — AsNoTracking on read-only list query
var query = _db.Proposals
.AsNoTracking()
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.AsQueryable();
@ -159,23 +179,44 @@ public class ProposalService : IProposalService
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
// Fix: API-M12 — capture before/after values for audit trail
var changes = new Dictionary<string, object>();
if (request.RefinedScope != null)
{
changes["refinedScope"] = new { old = proposal.RefinedScope, @new = request.RefinedScope };
proposal.RefinedScope = request.RefinedScope;
}
if (request.Notes != null)
{
changes["notes"] = new { old = proposal.Notes, @new = request.Notes };
proposal.Notes = request.Notes;
}
if (request.PoNumber != null)
{
changes["poNumber"] = new { old = proposal.PoNumber, @new = request.PoNumber };
proposal.PoNumber = request.PoNumber;
}
if (request.WorkOrderNumber != null)
{
changes["workOrderNumber"] = new { old = proposal.WorkOrderNumber, @new = request.WorkOrderNumber };
proposal.WorkOrderNumber = request.WorkOrderNumber;
}
if (request.AssignedAdminId.HasValue)
{
changes["assignedAdminId"] = new { old = proposal.AssignedAdminId, @new = request.AssignedAdminId.Value };
proposal.AssignedAdminId = request.AssignedAdminId.Value;
if (request.Status.HasValue && request.Status.Value == ProposalStatus.InReview
&& (proposal.Status == ProposalStatus.Draft || proposal.Status == ProposalStatus.Revised))
proposal.Status = request.Status.Value;
}
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.Edit, id, null, ct);
var auditDetails = changes.Count > 0 ? JsonSerializer.Serialize(changes) : null;
await _audit.LogAsync(AuditAction.Edit, id, auditDetails, ct);
return MapToResponse(proposal);
}
@ -190,14 +231,26 @@ public class ProposalService : IProposalService
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Approved)
{
_logger.LogInformation("Proposal {ProposalId} already approved, returning idempotent response", id);
return MapToResponse(proposal);
}
if (proposal.Status != ProposalStatus.InReview)
if (proposal.Status != ProposalStatus.InReview && proposal.Status != ProposalStatus.Revised)
{
_logger.LogWarning("Invalid state transition: cannot approve proposal {ProposalId} in status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only proposals in review can be approved");
}
if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0))
{
_logger.LogWarning("Cannot approve proposal {ProposalId}: no priced line items", id);
throw new InvalidOperationException("Cannot approve proposal without priced line items");
}
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.Approved;
proposal.ApprovedById = _currentUser.UserId;
proposal.ApprovedAt = DateTime.UtcNow;
@ -205,7 +258,39 @@ public class ProposalService : IProposalService
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.Approve, id, null, ct);
var approveAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Approved.ToString() } });
await _audit.LogAsync(AuditAction.Approve, id, approveAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} approved by user {UserId}, total bid {TotalBidAmount}",
id, _currentUser.UserId, proposal.TotalBidAmount);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.InReview)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be returned to review");
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.InReview;
proposal.ApprovedById = null;
proposal.ApprovedAt = null;
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var returnAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.InReview.ToString() } });
await _audit.LogAsync(AuditAction.ReturnToReview, id, returnAuditDetails, ct);
return MapToResponse(proposal);
}
@ -219,17 +304,29 @@ public class ProposalService : IProposalService
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Sent)
{
_logger.LogInformation("Proposal {ProposalId} already sent, returning idempotent response", id);
return MapToResponse(proposal);
}
if (proposal.Status != ProposalStatus.Approved)
{
_logger.LogWarning("Invalid state transition: cannot mark proposal {ProposalId} as sent from status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only approved proposals can be marked as sent");
}
// Fix: API-M12 — capture before/after status for audit trail
var previousStatus = proposal.Status;
proposal.Status = ProposalStatus.Sent;
proposal.SentAt = DateTime.UtcNow;
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.MarkSent, id, null, ct);
var sentAuditDetails = JsonSerializer.Serialize(new { status = new { old = previousStatus.ToString(), @new = ProposalStatus.Sent.ToString() } });
await _audit.LogAsync(AuditAction.MarkSent, id, sentAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} marked as sent by user {UserId}", id, _currentUser.UserId);
await _jobPublisher.PublishAsync("library-ingest", new { proposalId = id }, ct);
@ -248,17 +345,26 @@ public class ProposalService : IProposalService
var existingRevision = await _db.Proposals
.FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct);
if (existingRevision != null)
{
_logger.LogInformation("Proposal {ProposalId} already revised, returning existing revision {RevisionId}",
id, existingRevision.Id);
return MapToResponse(existingRevision);
}
}
if (proposal.Status != ProposalStatus.Sent)
{
_logger.LogWarning("Invalid state transition: cannot revise proposal {ProposalId} in status {CurrentStatus}",
id, proposal.Status);
throw new InvalidOperationException("Only sent proposals can be revised");
}
var revision = new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}",
WorkOrderNumber = proposal.WorkOrderNumber,
PoNumber = proposal.PoNumber,
CustomerName = proposal.CustomerName,
CustomerAddress = proposal.CustomerAddress,
ScopeOfWork = proposal.ScopeOfWork,
@ -296,13 +402,23 @@ public class ProposalService : IProposalService
});
}
// Fix: API-M12 — capture before/after status for audit trail
var previousReviseStatus = proposal.Status;
proposal.Status = ProposalStatus.Revised;
proposal.UpdatedAt = DateTime.UtcNow;
_db.Proposals.Add(revision);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, $"Revised from {proposal.Id}", ct);
var reviseAuditDetails = JsonSerializer.Serialize(new
{
status = new { old = previousReviseStatus.ToString(), @new = ProposalStatus.Revised.ToString() },
message = $"Revised from {proposal.Id}"
});
await _audit.LogAsync(AuditAction.CreateRevision, revision.Id, reviseAuditDetails, ct);
_logger.LogInformation("Proposal {ProposalId} revised to {RevisionId} (revision {RevisionNumber}) by user {UserId}",
id, revision.Id, revision.CurrentRevision, _currentUser.UserId);
return MapToResponse(revision);
}
@ -314,7 +430,9 @@ public class ProposalService : IProposalService
var rootId = proposal.ParentProposalId ?? proposal.Id;
// Fix: API-M7 — AsNoTracking on read-only revision history query
var revisions = await _db.Proposals
.AsNoTracking()
.Where(p => p.Id == rootId || p.ParentProposalId == rootId)
.OrderBy(p => p.CurrentRevision)
.ToListAsync(ct);
@ -324,7 +442,9 @@ public class ProposalService : IProposalService
public async Task<IReadOnlyList<AuditLogResponse>> GetAuditTrailAsync(Guid id, CancellationToken ct = default)
{
// Fix: API-M7 — AsNoTracking on read-only audit trail query
return await _db.AuditLogs
.AsNoTracking()
.Include(a => a.User)
.Where(a => a.ProposalId == id)
.OrderByDescending(a => a.Timestamp)
@ -343,10 +463,13 @@ public class ProposalService : IProposalService
public async Task<ProposalStatsResponse> GetStatsAsync(CancellationToken ct = default)
{
var userId = _currentUser.UserId;
// Fix: API-M7 — AsNoTracking on read-only stats query
var query = _db.Proposals.AsNoTracking().AsQueryable();
var counts = await _db.Proposals
.Where(p => p.SubmittedById == userId)
if (_currentUser.Role == UserRole.Dispatcher)
query = query.Where(p => p.SubmittedById == _currentUser.UserId);
var counts = await query
.GroupBy(_ => 1)
.Select(g => new
{
@ -366,6 +489,7 @@ public class ProposalService : IProposalService
p.Id,
p.ProposalNumber,
p.WorkOrderNumber,
p.PoNumber,
p.CustomerName,
p.CustomerAddress,
p.ScopeOfWork,

View file

@ -0,0 +1,52 @@
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
namespace ProposalSystem.Infrastructure.Services;
public class SiteService : ISiteService
{
private readonly IAmazonDynamoDB _dynamo;
private const string TableName = "verified-sites";
public SiteService(IAmazonDynamoDB dynamo)
{
_dynamo = dynamo;
}
public async Task<IReadOnlyList<SiteResponse>> SearchAsync(string query, CancellationToken ct = default)
{
var upper = query.ToUpper();
var request = new ScanRequest
{
TableName = TableName,
FilterExpression = "contains(siteCode, :q)",
ExpressionAttributeValues = new Dictionary<string, AttributeValue>
{
{ ":q", new AttributeValue { S = upper } }
},
Limit = 20,
};
var response = await _dynamo.ScanAsync(request, ct);
return response.Items
.Select(item => new SiteResponse(
GetString(item, "siteCode") ?? "",
GetString(item, "fullAddress"),
GetString(item, "address"),
GetString(item, "city"),
GetString(item, "state"),
GetString(item, "zip")
))
.OrderBy(s => s.SiteCode)
.ToList();
}
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
{
return item.TryGetValue(key, out var attr) ? attr.S : null;
}
}

View file

@ -0,0 +1,183 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProposalSystem.Api.Controllers;
using Xunit;
namespace ProposalSystem.Tests.Controllers;
/// <summary>
/// QA-C3: Authorization attribute tests.
/// Verifies that controllers and actions have correct [Authorize] and role requirements.
/// These are static metadata tests — they verify the security annotations exist
/// and are correct without needing to spin up an HTTP server.
/// </summary>
public class AuthorizationAttributeTests
{
#region Controller-Level Authorization
[Fact(DisplayName = "QA-C3: ProposalsController requires authentication")]
public void ProposalsController_HasAuthorizeAttribute()
{
typeof(ProposalsController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("ProposalsController should require authentication");
}
[Fact(DisplayName = "QA-C3: AdminController requires admins or sysadmins role")]
public void AdminController_RequiresAdminOrSysAdminRole()
{
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull("AdminController should require authentication");
attr!.Roles.Should().NotBeNull();
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins").And.Contain("sysadmins");
}
[Fact(DisplayName = "QA-C3: UsersController requires authentication")]
public void UsersController_HasAuthorizeAttribute()
{
typeof(UsersController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("UsersController should require authentication");
}
#endregion
#region Action-Level Role Requirements
[Theory(DisplayName = "QA-C3: Admin-only proposal actions require admins/sysadmins role")]
[InlineData("Update")]
[InlineData("Approve")]
[InlineData("MarkSent")]
[InlineData("Revise")]
[InlineData("GetAudit")]
[InlineData("GetSimilar")]
[InlineData("GenerateSuggestions")]
[InlineData("Regenerate")]
[InlineData("AddSimilarReference")]
public void ProposalsController_AdminActions_RequireAdminRole(string methodName)
{
var method = typeof(ProposalsController).GetMethod(methodName);
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins", $"{methodName} should allow admins")
.And.Contain("sysadmins", $"{methodName} should allow sysadmins");
}
[Theory(DisplayName = "QA-C3: Dispatcher-accessible proposal actions do NOT have role restrictions")]
[InlineData("Create")]
[InlineData("GetAll")]
[InlineData("GetById")]
[InlineData("GetHistory")]
[InlineData("GetStats")]
public void ProposalsController_DispatcherActions_NoRoleRestriction(string methodName)
{
var method = typeof(ProposalsController).GetMethod(methodName);
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
// These methods rely on controller-level [Authorize] but have no role restriction
if (attr != null)
{
attr.Roles.Should().BeNullOrEmpty($"{methodName} should be accessible to all authenticated users");
}
}
[Theory(DisplayName = "QA-C3: SysAdmin-only user management actions require sysadmins role")]
[InlineData("GetAll")]
[InlineData("UpdateRole")]
public void UsersController_SysAdminActions_RequireSysAdminRole(string methodName)
{
var method = typeof(UsersController).GetMethod(methodName);
method.Should().NotBeNull($"UsersController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("sysadmins", $"{methodName} should require sysadmins role");
}
[Fact(DisplayName = "QA-C3: UsersController.GetMe is accessible to all authenticated users")]
public void UsersController_GetMe_NoRoleRestriction()
{
var method = typeof(UsersController).GetMethod("GetMe");
method.Should().NotBeNull();
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
// GetMe should rely on controller-level [Authorize] without role restriction
if (attr != null)
{
attr.Roles.Should().BeNullOrEmpty("GetMe should be accessible to all authenticated users");
}
}
#endregion
#region Role Hierarchy Verification
[Fact(DisplayName = "QA-C3: Dispatchers cannot access admin dashboard")]
public void AdminController_NotAccessibleToDispatchers()
{
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().NotBeNull();
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("dispatchers",
"dispatchers must not have access to admin controller");
}
[Fact(DisplayName = "QA-C3: Dispatchers cannot access user management")]
public void UsersController_GetAll_NotAccessibleToDispatchers()
{
var method = typeof(UsersController).GetMethod("GetAll");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().NotBeNull();
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("dispatchers",
"dispatchers must not have access to user management");
}
[Fact(DisplayName = "QA-C3: Admins cannot access sysadmin-only user management")]
public void UsersController_UpdateRole_NotAccessibleToAdmins()
{
var method = typeof(UsersController).GetMethod("UpdateRole");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
var roles = attr!.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("admins",
"admins must not have access to role management (sysadmins only)");
}
#endregion
#region All Controllers Must Have [Authorize]
[Theory(DisplayName = "QA-C3: All API controllers (except AuthController) require authentication")]
[InlineData(typeof(ProposalsController))]
[InlineData(typeof(AdminController))]
[InlineData(typeof(UsersController))]
[InlineData(typeof(CustomersController))]
[InlineData(typeof(LineItemsController))]
[InlineData(typeof(GeneratedPdfsController))]
[InlineData(typeof(FilesController))]
[InlineData(typeof(VendorProposalsController))]
public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
{
var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull(
$"{controllerType.Name} must have [Authorize] attribute to prevent unauthenticated access");
}
#endregion
}

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Tests.Helpers;
/// <summary>
/// Creates isolated in-memory DbContext instances for unit tests.
/// Each call produces a uniquely-named database so tests don't leak state.
/// Transaction warnings are suppressed since InMemory provider does not support them.
/// </summary>
public static class DbContextFactory
{
public static ProposalDbContext Create()
{
var options = new DbContextOptionsBuilder<ProposalDbContext>()
.UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
var context = new ProposalDbContext(options);
context.Database.EnsureCreated();
return context;
}
}

View file

@ -0,0 +1,48 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Infrastructure.Data;
namespace ProposalSystem.Tests.Helpers;
/// <summary>
/// Creates SQLite-backed in-memory DbContext instances for tests that require relational
/// features (e.g., ExecuteSqlRawAsync, transactions). The SQLite connection is kept open
/// for the lifetime of the context; disposing the context closes the connection and
/// discards the in-memory database.
///
/// Registers stub Postgres functions (hashtext, pg_advisory_xact_lock) so that
/// production SQL using these functions does not throw in the test environment.
/// </summary>
public static class SqliteDbContextFactory
{
/// <summary>
/// Creates a new ProposalDbContext backed by a unique SQLite in-memory database.
/// The returned context owns the connection; dispose the context to clean up.
/// </summary>
public static ProposalDbContext Create()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
// Register Postgres-specific function stubs so ExecuteSqlRawAsync calls
// like "SELECT pg_advisory_xact_lock(hashtext('...'))" succeed in SQLite.
RegisterPostgresStubs(connection);
var options = new DbContextOptionsBuilder<ProposalDbContext>()
.UseSqlite(connection)
.Options;
var context = new ProposalDbContext(options);
context.Database.EnsureCreated();
return context;
}
private static void RegisterPostgresStubs(SqliteConnection connection)
{
// hashtext(text) -> integer — returns a constant; only needed so SQL parses
connection.CreateFunction("hashtext", (string _) => 0);
// pg_advisory_xact_lock(bigint) -> void — no-op in tests
connection.CreateFunction("pg_advisory_xact_lock", (long _) => 0);
}
}

View file

@ -0,0 +1,207 @@
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ProposalSystem.Api.Middleware;
using Xunit;
namespace ProposalSystem.Tests.Middleware;
/// <summary>
/// QA-C4: InternalApiKeyMiddleware tests.
/// Validates that internal API key authentication works correctly:
/// - Valid key on any path sets system claims and calls next (current behavior)
/// - Invalid key logs warning but still calls next (passes through to JWT)
/// - Missing key header passes through to next middleware (JWT auth)
/// - Empty key in config disables the middleware entirely
///
/// Note: API-C1 audit finding identified that this middleware applies globally
/// and bypasses JWT on ANY route when a valid key is provided. These tests
/// document the current behavior; the fix should scope keys to /internal/ paths.
/// </summary>
public class InternalApiKeyMiddlewareTests
{
private const string ValidApiKey = "test-internal-api-key-secret-value";
[Fact(DisplayName = "QA-C4: Valid key sets system claims and calls next")]
public async Task ValidKey_SetsClaimsAndCallsNext()
{
// Arrange
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
context.Request.Path = "/api/proposals/123";
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled().Should().BeTrue("next middleware should be called");
context.User.Identity!.IsAuthenticated.Should().BeTrue();
context.User.Identity!.AuthenticationType.Should().Be("InternalApiKey");
context.User.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be("system");
context.User.FindFirst("sub")!.Value.Should().Be("system-lambda-caller");
context.User.FindFirst(ClaimTypes.Role)!.Value.Should().Be("admins");
context.User.FindFirst("cognito:groups")!.Value.Should().Be("admins");
context.User.FindFirst(ClaimTypes.Email)!.Value.Should().Be("system@proposal-system.internal");
}
[Fact(DisplayName = "QA-C4/API-H1: Invalid key returns 401 immediately")]
public async Task InvalidKey_Returns401()
{
// Arrange
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = "wrong-key";
context.Request.Path = "/api/proposals/123";
// Act
await middleware.InvokeAsync(context);
// Assert — API-H1 fix: invalid key short-circuits with 401
nextCalled().Should().BeFalse("invalid key should not fall through");
context.Response.StatusCode.Should().Be(401);
}
[Fact(DisplayName = "QA-C4: Missing key header passes through to next middleware")]
public async Task MissingKeyHeader_PassesThrough()
{
// Arrange
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
// No X-Internal-Api-Key header set
context.Request.Path = "/api/proposals";
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled().Should().BeTrue("request should pass through to next middleware");
context.User.Identity!.IsAuthenticated.Should().BeFalse();
}
[Fact(DisplayName = "QA-C4: Empty key in config disables API key check entirely")]
public async Task EmptyKeyInConfig_DisablesMiddleware()
{
// Arrange - empty config key means middleware is effectively disabled
var (middleware, context, nextCalled) = CreateMiddleware("");
context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
context.Request.Path = "/api/proposals/123";
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled().Should().BeTrue("next middleware should be called");
context.User.Identity!.IsAuthenticated.Should().BeFalse(
"middleware is disabled when config key is empty");
}
[Fact(DisplayName = "QA-C4: Null config key disables API key check")]
public async Task NullConfigKey_DisablesMiddleware()
{
// Arrange - null config key (INTERNAL_API_KEY not set)
var (middleware, context, nextCalled) = CreateMiddleware(null);
context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled().Should().BeTrue();
context.User.Identity!.IsAuthenticated.Should().BeFalse();
}
[Fact(DisplayName = "QA-C4: Empty header value passes through")]
public async Task EmptyHeaderValue_PassesThrough()
{
// Arrange
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = "";
// Act
await middleware.InvokeAsync(context);
// Assert
nextCalled().Should().BeTrue();
context.User.Identity!.IsAuthenticated.Should().BeFalse();
}
[Fact(DisplayName = "QA-C4: Timing-safe comparison used (key differs by one char)")]
public async Task SimilarKey_Returns401()
{
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey + "x";
context.Request.Path = "/api/proposals/123";
// Act
await middleware.InvokeAsync(context);
// Assert — API-H1 fix: invalid key short-circuits with 401
nextCalled().Should().BeFalse("similar but wrong key should not fall through");
context.Response.StatusCode.Should().Be(401);
}
[Fact(DisplayName = "QA-C4/API-C1: Valid key on allowed path authenticates")]
public async Task ValidKey_AllowedPath_Authenticates()
{
var allowedPaths = new[] { "/api/proposals", "/api/vendor-proposals/1", "/api/generated-pdfs", "/api/files/upload" };
foreach (var path in allowedPaths)
{
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
context.Request.Path = path;
await middleware.InvokeAsync(context);
context.User.Identity!.IsAuthenticated.Should().BeTrue(
$"valid key should authenticate on allowed path {path}");
nextCalled().Should().BeTrue();
}
}
[Fact(DisplayName = "QA-C4/API-C1: Valid key on disallowed path returns 403")]
public async Task ValidKey_DisallowedPath_Returns403()
{
var disallowedPaths = new[] { "/api/admin/dashboard", "/api/users", "/health" };
foreach (var path in disallowedPaths)
{
var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
context.Request.Path = path;
await middleware.InvokeAsync(context);
nextCalled().Should().BeFalse($"valid key on disallowed path {path} should not call next");
context.Response.StatusCode.Should().Be(403);
}
}
private static (InternalApiKeyMiddleware middleware, HttpContext context, Func<bool> nextCalled) CreateMiddleware(string? configuredKey)
{
var wasNextCalled = false;
RequestDelegate next = _ =>
{
wasNextCalled = true;
return Task.CompletedTask;
};
var configData = new Dictionary<string, string?>();
if (configuredKey != null)
{
configData["INTERNAL_API_KEY"] = configuredKey;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
var logger = Substitute.For<ILogger<InternalApiKeyMiddleware>>();
var middleware = new InternalApiKeyMiddleware(next, configuration, logger);
var context = new DefaultHttpContext();
return (middleware, context, () => wasNextCalled);
}
}

View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ProposalSystem.Api\ProposalSystem.Api.csproj" />
<ProjectReference Include="..\..\src\ProposalSystem.Application\ProposalSystem.Application.csproj" />
<ProjectReference Include="..\..\src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\ProposalSystem.Domain\ProposalSystem.Domain.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,421 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// LineItemService state guard tests.
/// Verifies that line items cannot be created, bulk-updated, or deleted on
/// proposals in Approved or Sent status. Line item operations on InReview
/// and Revised proposals must succeed.
///
/// Related findings: QA-C2 (state machine enforcement), API-H3 (status field exposure).
/// </summary>
public class LineItemServiceStateGuardTests : IDisposable
{
private readonly Infrastructure.Data.ProposalDbContext _db;
private readonly IAuditService _audit;
private readonly LineItemService _sut;
private readonly Guid _userId;
public LineItemServiceStateGuardTests()
{
_db = DbContextFactory.Create();
_audit = Substitute.For<IAuditService>();
_userId = Guid.NewGuid();
_db.Users.Add(new User
{
Id = _userId,
CognitoSub = $"sub-{_userId}",
Email = "admin@test.com",
DisplayName = "Test Admin",
Role = UserRole.Admin,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.SaveChanges();
_sut = new LineItemService(_db, _audit, Substitute.For<ILogger<LineItemService>>());
}
public void Dispose()
{
_db.Dispose();
}
#region CreateAsync Guards
[Theory(DisplayName = "QA-C2: CreateAsync throws on Approved and Sent proposals")]
[InlineData(ProposalStatus.Approved)]
[InlineData(ProposalStatus.Sent)]
public async Task CreateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
{
// Arrange
var proposal = CreateProposal(status);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = CreateLineItemRequest();
// Act
var act = () => _sut.CreateAsync(proposal.Id, request);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*approved/sent*");
}
[Theory(DisplayName = "QA-C2: CreateAsync succeeds on InReview and Revised proposals")]
[InlineData(ProposalStatus.InReview)]
[InlineData(ProposalStatus.Revised)]
public async Task CreateAsync_OnEditableStatus_Succeeds(ProposalStatus status)
{
// Arrange
var proposal = CreateProposal(status);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = CreateLineItemRequest();
// Act
var result = await _sut.CreateAsync(proposal.Id, request);
// Assert
result.Should().NotBeNull();
result.Description.Should().Be(request.Description);
result.ProposalId.Should().Be(proposal.Id);
}
[Fact(DisplayName = "QA-C2: CreateAsync throws KeyNotFoundException for nonexistent proposal")]
public async Task CreateAsync_NonexistentProposal_ThrowsKeyNotFound()
{
var act = () => _sut.CreateAsync(Guid.NewGuid(), CreateLineItemRequest());
await act.Should().ThrowAsync<KeyNotFoundException>();
}
#endregion
#region BulkUpdateAsync Guards
[Theory(DisplayName = "QA-C2: BulkUpdateAsync throws on Approved and Sent proposals")]
[InlineData(ProposalStatus.Approved)]
[InlineData(ProposalStatus.Sent)]
public async Task BulkUpdateAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
{
// Arrange
var proposal = CreateProposal(status);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
{
new(null, "Updated item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
var act = () => _sut.BulkUpdateAsync(proposal.Id, request);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*approved/sent*");
}
[Fact(DisplayName = "QA-C2: BulkUpdateAsync succeeds on InReview proposal")]
public async Task BulkUpdateAsync_OnInReview_Succeeds()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
{
new(null, "Item A", 2, "hours", 50m, 100m, PricingMode.UnitPrice, 1, LineItemSource.Manual),
new(null, "Item B", 1, "each", null, 200m, PricingMode.TotalPrice, 2, LineItemSource.AI),
});
// Act
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
// Assert
result.Should().HaveCount(2);
result.Select(r => r.Description).Should().BeEquivalentTo("Item A", "Item B");
}
[Fact(DisplayName = "QA-C2: BulkUpdateAsync updates proposal TotalBidAmount")]
public async Task BulkUpdateAsync_UpdatesTotalBidAmount()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
{
new(null, "Item A", 1, "each", null, 500m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
new(null, "Item B", 1, "each", null, 300m, PricingMode.TotalPrice, 2, LineItemSource.Manual),
});
// Act
await _sut.BulkUpdateAsync(proposal.Id, request);
// Assert
var updated = await _db.Proposals.FindAsync(proposal.Id);
updated!.TotalBidAmount.Should().Be(800m);
}
[Fact(DisplayName = "QA-C2: BulkUpdateAsync replaces all existing line items")]
public async Task BulkUpdateAsync_ReplacesExistingItems()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Old item",
Quantity = 1,
Unit = "each",
TotalPrice = 999m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
{
new(null, "New item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
var result = await _sut.BulkUpdateAsync(proposal.Id, request);
// Assert
result.Should().HaveCount(1);
result[0].Description.Should().Be("New item");
var dbItems = _db.LineItems.Where(li => li.ProposalId == proposal.Id).ToList();
dbItems.Should().HaveCount(1);
dbItems[0].Description.Should().Be("New item");
}
[Fact(DisplayName = "QA-C2: BulkUpdateAsync throws KeyNotFoundException for nonexistent proposal")]
public async Task BulkUpdateAsync_NonexistentProposal_ThrowsKeyNotFound()
{
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>());
var act = () => _sut.BulkUpdateAsync(Guid.NewGuid(), request);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
#endregion
#region DeleteAsync Guards
[Theory(DisplayName = "QA-C2: DeleteAsync throws on Approved and Sent proposals")]
[InlineData(ProposalStatus.Approved)]
[InlineData(ProposalStatus.Sent)]
public async Task DeleteAsync_OnLockedStatus_ThrowsInvalidOperation(ProposalStatus status)
{
// Arrange
var proposal = CreateProposal(status);
var lineItem = new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Item to delete",
Quantity = 1,
Unit = "each",
TotalPrice = 100m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
proposal.LineItems.Add(lineItem);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.DeleteAsync(proposal.Id, lineItem.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*approved/sent*");
}
[Fact(DisplayName = "QA-C2: DeleteAsync succeeds on InReview proposal")]
public async Task DeleteAsync_OnInReview_Succeeds()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
var lineItem = new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Item to delete",
Quantity = 1,
Unit = "each",
TotalPrice = 100m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
proposal.LineItems.Add(lineItem);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
await _sut.DeleteAsync(proposal.Id, lineItem.Id);
// Assert
var deleted = await _db.LineItems.FindAsync(lineItem.Id);
deleted.Should().BeNull();
}
[Fact(DisplayName = "QA-C2: DeleteAsync throws KeyNotFoundException for nonexistent line item")]
public async Task DeleteAsync_NonexistentLineItem_ThrowsKeyNotFound()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.DeleteAsync(proposal.Id, Guid.NewGuid());
// Assert
await act.Should().ThrowAsync<KeyNotFoundException>();
}
#endregion
#region Audit Logging
[Fact(DisplayName = "QA-C2: CreateAsync logs audit event on success")]
public async Task CreateAsync_LogsAuditEvent()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = CreateLineItemRequest();
// Act
await _sut.CreateAsync(proposal.Id, request);
// Assert
await _audit.Received(1).LogAsync(
AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null && s.Contains(request.Description)),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "QA-C2: BulkUpdateAsync logs audit event on success")]
public async Task BulkUpdateAsync_LogsAuditEvent()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var request = new BulkUpdateLineItemsRequest(new List<UpdateLineItemEntry>
{
new(null, "Item", 1, "each", null, 100m, PricingMode.TotalPrice, 1, LineItemSource.Manual),
});
// Act
await _sut.BulkUpdateAsync(proposal.Id, request);
// Assert — audit detail may be plain text ("Bulk update: N items") or JSON
await _audit.Received(1).LogAsync(
AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null),
Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "QA-C2: DeleteAsync logs audit event on success")]
public async Task DeleteAsync_LogsAuditEvent()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
var lineItem = new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Audit test item",
Quantity = 1,
Unit = "each",
TotalPrice = 100m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
proposal.LineItems.Add(lineItem);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
await _sut.DeleteAsync(proposal.Id, lineItem.Id);
// Assert
await _audit.Received(1).LogAsync(
AuditAction.EditLineItem,
proposal.Id,
Arg.Is<string?>(s => s != null && s.Contains("Audit test item")),
Arg.Any<CancellationToken>());
}
#endregion
private Proposal CreateProposal(ProposalStatus status)
{
return new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"SHI-2026-{Guid.NewGuid():N}"[..16],
WorkOrderNumber = "WO-TEST",
CustomerName = "Test Customer",
CustomerAddress = "123 Test St",
ScopeOfWork = "Test scope of work",
ServiceCategory = ServiceCategory.General,
Priority = Priority.Standard,
Status = status,
Notes = "",
SubmittedById = _userId,
SubmittedAt = DateTime.UtcNow,
CurrentRevision = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
}
private static CreateLineItemRequest CreateLineItemRequest()
{
return new CreateLineItemRequest(
Description: "HVAC duct replacement",
Quantity: 2,
Unit: "each",
UnitPrice: 250m,
TotalPrice: 500m,
PricingMode: PricingMode.UnitPrice,
SortOrder: 1,
Source: LineItemSource.Manual
);
}
}

View file

@ -0,0 +1,218 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// Tests for ProposalNumberGenerator.
/// Verifies format (SHI-YYYY-NNNN), uniqueness, and sequence logic.
/// Uses SQLite in-memory provider because the generator calls ExecuteSqlRawAsync
/// for pg_advisory_xact_lock, which requires a relational provider (InMemory throws).
/// SQLite silently accepts the Postgres-specific SQL, allowing the sequence logic to
/// be exercised end-to-end.
///
/// Related findings: QA-C1 (test coverage gaps).
/// </summary>
public class ProposalNumberGeneratorTests : IDisposable
{
private readonly Infrastructure.Data.ProposalDbContext _db;
private readonly ProposalNumberGenerator _sut;
private readonly Guid _userId;
public ProposalNumberGeneratorTests()
{
_db = SqliteDbContextFactory.Create();
// Create a user required by FK constraints on Proposal.SubmittedById
_userId = Guid.NewGuid();
_db.Users.Add(new User
{
Id = _userId,
CognitoSub = $"sub-{_userId}",
Email = "test@seahavenind.com",
DisplayName = "Test User",
Role = UserRole.Dispatcher,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.SaveChanges();
_sut = new ProposalNumberGenerator(_db);
}
public void Dispose()
{
_db.Dispose();
}
[Fact(DisplayName = "QA-C1: GenerateAsync returns SHI-YYYY-0001 format when no prior proposals")]
public async Task GenerateAsync_NoPriorProposals_ReturnsFirstSequenceNumber()
{
// Act
var result = await _sut.GenerateAsync();
// Assert
var year = DateTime.UtcNow.Year;
result.Should().Be($"SHI-{year}-0001");
}
[Fact(DisplayName = "QA-C1: GenerateAsync format matches SHI-YYYY-NNNN pattern")]
public async Task GenerateAsync_MatchesExpectedFormat()
{
// Act
var result = await _sut.GenerateAsync();
// Assert
result.Should().MatchRegex(@"^SHI-\d{4}-\d{4}$");
}
[Fact(DisplayName = "QA-C1: GenerateAsync increments sequence from last proposal")]
public async Task GenerateAsync_WithExistingProposals_IncrementsSequence()
{
// Arrange — seed a proposal with number ending in 0042
var year = DateTime.UtcNow.Year;
_db.Proposals.Add(CreateProposal($"SHI-{year}-0042"));
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert
result.Should().Be($"SHI-{year}-0043");
}
[Fact(DisplayName = "QA-C1: GenerateAsync skips revision proposals (containing -R)")]
public async Task GenerateAsync_IgnoresRevisionProposals()
{
// Arrange — seed a regular and a revision proposal
var year = DateTime.UtcNow.Year;
_db.Proposals.Add(CreateProposal($"SHI-{year}-0010"));
_db.Proposals.Add(CreateProposal($"SHI-{year}-0010-R2")); // revision should be ignored
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert — next after 0010, not after the revision
result.Should().Be($"SHI-{year}-0011");
}
[Fact(DisplayName = "QA-C1: GenerateAsync ignores proposals from different years")]
public async Task GenerateAsync_IgnoresDifferentYearProposals()
{
// Arrange — seed proposals from a previous year
var lastYear = DateTime.UtcNow.Year - 1;
_db.Proposals.Add(CreateProposal($"SHI-{lastYear}-0099"));
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert — starts at 0001 for the current year
var year = DateTime.UtcNow.Year;
result.Should().Be($"SHI-{year}-0001");
}
[Fact(DisplayName = "QA-C1: GenerateAsync produces unique numbers across multiple calls")]
public async Task GenerateAsync_MultipleCalls_ProducesUniqueNumbers()
{
// Act — generate several numbers, persisting each to DB between calls
var numbers = new List<string>();
for (int i = 0; i < 5; i++)
{
var number = await _sut.GenerateAsync();
numbers.Add(number);
// Persist so the next call sees it
_db.Proposals.Add(CreateProposal(number));
await _db.SaveChangesAsync();
}
// Assert
numbers.Should().OnlyHaveUniqueItems();
var year = DateTime.UtcNow.Year;
numbers.Should().BeEquivalentTo(new[]
{
$"SHI-{year}-0001",
$"SHI-{year}-0002",
$"SHI-{year}-0003",
$"SHI-{year}-0004",
$"SHI-{year}-0005",
});
}
[Fact(DisplayName = "QA-C1: GenerateAsync zero-pads sequence to 4 digits")]
public async Task GenerateAsync_ZeroPadsToFourDigits()
{
// Arrange
var year = DateTime.UtcNow.Year;
_db.Proposals.Add(CreateProposal($"SHI-{year}-0003"));
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert
result.Should().EndWith("-0004");
// Verify the sequence part is exactly 4 characters
var sequencePart = result.Split('-').Last();
sequencePart.Should().HaveLength(4);
}
[Fact(DisplayName = "QA-C1: GenerateAsync handles sequence above 9999 (5+ digits)")]
public async Task GenerateAsync_HighSequenceNumber_StillFormatsCorrectly()
{
// Arrange — seed a proposal at 9999
var year = DateTime.UtcNow.Year;
_db.Proposals.Add(CreateProposal($"SHI-{year}-9999"));
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert — D4 format will produce 5 digits at 10000
result.Should().Be($"SHI-{year}-10000");
}
[Fact(DisplayName = "QA-C1: GenerateAsync picks highest existing sequence, not last inserted")]
public async Task GenerateAsync_OutOfOrderInsertion_PicksHighest()
{
// Arrange — insert out of order
var year = DateTime.UtcNow.Year;
_db.Proposals.Add(CreateProposal($"SHI-{year}-0050"));
_db.Proposals.Add(CreateProposal($"SHI-{year}-0020"));
_db.Proposals.Add(CreateProposal($"SHI-{year}-0075"));
await _db.SaveChangesAsync();
// Act
var result = await _sut.GenerateAsync();
// Assert — should pick 75 + 1 = 76 since OrderByDescending picks the highest
result.Should().Be($"SHI-{year}-0076");
}
private Proposal CreateProposal(string proposalNumber)
{
return new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = proposalNumber,
WorkOrderNumber = "WO-TEST",
CustomerName = "Test Customer",
CustomerAddress = "123 Test St",
ScopeOfWork = "Test scope",
ServiceCategory = ServiceCategory.General,
Priority = Priority.Standard,
Status = ProposalStatus.InReview,
Notes = "",
SubmittedById = _userId,
SubmittedAt = DateTime.UtcNow,
CurrentRevision = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
}
}

View file

@ -0,0 +1,432 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Services;
using ProposalSystem.Tests.Helpers;
using Xunit;
namespace ProposalSystem.Tests.Services;
/// <summary>
/// QA-C2: Proposal state machine transition tests.
/// Verifies that only valid state transitions succeed and invalid ones throw.
/// State machine: InReview -> Approved -> Sent -> Revised (creates new proposal).
/// </summary>
public class ProposalStateMachineTests : IDisposable
{
private readonly Infrastructure.Data.ProposalDbContext _db;
private readonly ICurrentUserService _currentUser;
private readonly IAuditService _audit;
private readonly IJobPublisher _jobPublisher;
private readonly IProposalNumberGenerator _numberGenerator;
private readonly ProposalService _sut;
public ProposalStateMachineTests()
{
_db = DbContextFactory.Create();
_currentUser = Substitute.For<ICurrentUserService>();
_audit = Substitute.For<IAuditService>();
_jobPublisher = Substitute.For<IJobPublisher>();
_numberGenerator = Substitute.For<IProposalNumberGenerator>();
var userId = Guid.NewGuid();
_currentUser.UserId.Returns(userId);
_currentUser.Role.Returns(UserRole.Admin);
// InMemory provider enforces FK constraints; create the required User entity
_db.Users.Add(new User
{
Id = userId,
CognitoSub = $"sub-{userId}",
Email = "admin@test.com",
DisplayName = "Test Admin",
Role = UserRole.Admin,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
_db.SaveChanges();
_sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher,
Substitute.For<ILogger<ProposalService>>());
}
public void Dispose()
{
_db.Dispose();
}
#region Valid Transitions
[Fact(DisplayName = "QA-C2: InReview -> Approved succeeds when line items have prices")]
public async Task ApproveAsync_InReview_TransitionsToApproved()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "HVAC duct replacement",
Quantity = 1,
Unit = "each",
TotalPrice = 5000m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.AI,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.ApproveAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Approved);
result.ApprovedById.Should().Be(_currentUser.UserId);
result.TotalBidAmount.Should().Be(5000m);
}
[Fact(DisplayName = "QA-C2: Approved -> Sent succeeds")]
public async Task MarkSentAsync_Approved_TransitionsToSent()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
result.SentAt.Should().NotBeNull();
}
[Fact(DisplayName = "QA-C2: Sent -> Revised creates new revision proposal")]
public async Task ReviseAsync_Sent_CreatesNewProposalInReview()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Sent);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Pipe repair",
Quantity = 2,
Unit = "hours",
UnitPrice = 150m,
TotalPrice = 300m,
PricingMode = PricingMode.UnitPrice,
Source = LineItemSource.Manual,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.ReviseAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.InReview);
result.ParentProposalId.Should().Be(proposal.Id);
result.CurrentRevision.Should().Be(proposal.CurrentRevision + 1);
result.ProposalNumber.Should().Contain("-R");
// Original proposal should now be Revised
var original = await _db.Proposals.FindAsync(proposal.Id);
original!.Status.Should().Be(ProposalStatus.Revised);
}
[Fact(DisplayName = "QA-C2: Approve is idempotent on already-approved proposal")]
public async Task ApproveAsync_AlreadyApproved_ReturnsWithoutError()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.ApproveAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Approved);
}
[Fact(DisplayName = "QA-C2: MarkSent is idempotent on already-sent proposal")]
public async Task MarkSentAsync_AlreadySent_ReturnsWithoutError()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Sent);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.MarkSentAsync(proposal.Id);
// Assert
result.Status.Should().Be(ProposalStatus.Sent);
}
#endregion
#region Invalid Transitions
[Fact(DisplayName = "QA-C2: InReview -> Sent is invalid, must approve first")]
public async Task MarkSentAsync_InReview_ThrowsInvalidOperation()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.MarkSentAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*approved*");
}
[Fact(DisplayName = "QA-C2: Approved -> Revised is invalid, must send first")]
public async Task ReviseAsync_Approved_ThrowsInvalidOperation()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.ReviseAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*sent*");
}
[Fact(DisplayName = "QA-C2: Draft -> Approved is invalid")]
public async Task ApproveAsync_Draft_ThrowsInvalidOperation()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Draft);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Some work",
Quantity = 1,
Unit = "each",
TotalPrice = 100m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.ApproveAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*in review*");
}
[Fact(DisplayName = "QA-C2: InReview -> Revised is invalid")]
public async Task ReviseAsync_InReview_ThrowsInvalidOperation()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.ReviseAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*sent*");
}
[Fact(DisplayName = "QA-C2: Sent -> Approved is invalid")]
public async Task MarkSentAsync_Sent_StaysIdempotent_But_ApproveThrows()
{
// Sent cannot go back to Approved
var proposal = CreateProposal(ProposalStatus.Sent);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
var act = () => _sut.ApproveAsync(proposal.Id);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*in review*");
}
[Fact(DisplayName = "QA-C2: Cannot approve proposal without priced line items")]
public async Task ApproveAsync_NoLineItems_ThrowsInvalidOperation()
{
// Arrange - proposal InReview but no line items
var proposal = CreateProposal(ProposalStatus.InReview);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.ApproveAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*priced line items*");
}
[Fact(DisplayName = "QA-C2: Cannot approve proposal with zero-price line items only")]
public async Task ApproveAsync_AllZeroPriceLineItems_ThrowsInvalidOperation()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Unprice item",
Quantity = 1,
Unit = "each",
TotalPrice = 0m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.AI,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var act = () => _sut.ApproveAsync(proposal.Id);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*priced line items*");
}
[Fact(DisplayName = "QA-C2: Approve/Send/Revise on nonexistent proposal throws KeyNotFoundException")]
public async Task StateTransitions_NonexistentProposal_ThrowsKeyNotFound()
{
var missingId = Guid.NewGuid();
var approveAct = () => _sut.ApproveAsync(missingId);
var sendAct = () => _sut.MarkSentAsync(missingId);
var reviseAct = () => _sut.ReviseAsync(missingId);
await approveAct.Should().ThrowAsync<KeyNotFoundException>();
await sendAct.Should().ThrowAsync<KeyNotFoundException>();
await reviseAct.Should().ThrowAsync<KeyNotFoundException>();
}
#endregion
#region Revision Edge Cases
[Fact(DisplayName = "QA-C2: Revision copies line items from parent")]
public async Task ReviseAsync_CopiesLineItems()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Sent);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Item 1",
Quantity = 5,
Unit = "sq ft",
UnitPrice = 10m,
TotalPrice = 50m,
PricingMode = PricingMode.UnitPrice,
SortOrder = 1,
Source = LineItemSource.Manual,
});
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Item 2",
Quantity = 1,
Unit = "each",
TotalPrice = 200m,
PricingMode = PricingMode.TotalPrice,
SortOrder = 2,
Source = LineItemSource.AI,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
var result = await _sut.ReviseAsync(proposal.Id);
// Assert
var revision = await _db.Proposals.FindAsync(result.Id);
var revisionItems = _db.LineItems.Where(li => li.ProposalId == result.Id).ToList();
revisionItems.Should().HaveCount(2);
revisionItems.Should().AllSatisfy(li => li.ProposalId.Should().Be(result.Id));
revisionItems.Select(li => li.Description).Should().BeEquivalentTo("Item 1", "Item 2");
}
[Fact(DisplayName = "QA-C2: Audit is logged for approve transition")]
public async Task ApproveAsync_LogsAuditEvent()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.InReview);
proposal.LineItems.Add(new LineItem
{
Id = Guid.NewGuid(),
ProposalId = proposal.Id,
Description = "Work item",
Quantity = 1,
Unit = "each",
TotalPrice = 1000m,
PricingMode = PricingMode.TotalPrice,
Source = LineItemSource.Manual,
});
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
await _sut.ApproveAsync(proposal.Id);
// Assert
await _audit.Received(1).LogAsync(AuditAction.Approve, proposal.Id, Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
[Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")]
public async Task MarkSentAsync_PublishesLibraryIngestJob()
{
// Arrange
var proposal = CreateProposal(ProposalStatus.Approved);
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync();
// Act
await _sut.MarkSentAsync(proposal.Id);
// Assert
await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any<object>(), Arg.Any<CancellationToken>());
}
#endregion
private Proposal CreateProposal(ProposalStatus status)
{
return new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = $"P-{Guid.NewGuid():N}".Substring(0, 12),
WorkOrderNumber = "WO-001",
CustomerName = "Test Customer",
CustomerAddress = "123 Test St",
ScopeOfWork = "Test scope of work",
ServiceCategory = ServiceCategory.HVAC,
Priority = Priority.Standard,
Status = status,
Notes = "",
SubmittedById = _currentUser.UserId,
SubmittedAt = DateTime.UtcNow.AddDays(-1),
CurrentRevision = 1,
CreatedAt = DateTime.UtcNow.AddDays(-1),
UpdatedAt = DateTime.UtcNow.AddDays(-1),
};
}
}

View file

@ -0,0 +1,129 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// Tests for CreateLineItemValidator — validates line item payloads.
/// </summary>
public class CreateLineItemValidatorTests
{
private readonly CreateLineItemValidator _sut = new();
[Fact(DisplayName = "Valid line item request passes validation")]
public void ValidRequest_Passes()
{
var request = new CreateLineItemRequest(
Description: "HVAC duct replacement",
Quantity: 10,
Unit: "linear ft",
UnitPrice: 25.50m,
TotalPrice: 255.00m,
PricingMode: PricingMode.UnitPrice,
SortOrder: 1,
Source: LineItemSource.Manual
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Empty description fails")]
public void EmptyDescription_Fails()
{
var request = new CreateLineItemRequest("", 1, "each", null, 100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Description");
}
[Fact(DisplayName = "Zero quantity fails")]
public void ZeroQuantity_Fails()
{
var request = new CreateLineItemRequest("Work item", 0, "each", null, 100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Quantity");
}
[Fact(DisplayName = "Negative quantity fails")]
public void NegativeQuantity_Fails()
{
var request = new CreateLineItemRequest("Work item", -5, "each", null, 100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Quantity");
}
[Fact(DisplayName = "Negative total price fails")]
public void NegativeTotalPrice_Fails()
{
var request = new CreateLineItemRequest("Work item", 1, "each", null, -100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "TotalPrice");
}
[Fact(DisplayName = "Negative unit price fails")]
public void NegativeUnitPrice_Fails()
{
var request = new CreateLineItemRequest("Work item", 1, "each", -10m, 100m,
PricingMode.UnitPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "UnitPrice");
}
[Fact(DisplayName = "Null unit price is valid (lump sum pricing)")]
public void NullUnitPrice_Passes()
{
var request = new CreateLineItemRequest("Work item", 1, "each", null, 100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Empty unit fails")]
public void EmptyUnit_Fails()
{
var request = new CreateLineItemRequest("Work item", 1, "", null, 100m,
PricingMode.TotalPrice, 1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Unit");
}
[Fact(DisplayName = "Negative sort order fails")]
public void NegativeSortOrder_Fails()
{
var request = new CreateLineItemRequest("Work item", 1, "each", null, 100m,
PricingMode.TotalPrice, -1, LineItemSource.Manual);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "SortOrder");
}
}

View file

@ -0,0 +1,189 @@
using FluentAssertions;
using ProposalSystem.Application.DTOs;
using ProposalSystem.Application.Validators;
using ProposalSystem.Domain.Entities;
using Xunit;
namespace ProposalSystem.Tests.Validators;
/// <summary>
/// Tests for CreateProposalValidator — validates request payloads before
/// they hit the service layer.
/// </summary>
public class CreateProposalValidatorTests
{
private readonly CreateProposalValidator _sut = new();
[Fact(DisplayName = "Valid proposal request passes validation")]
public void ValidRequest_Passes()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Acme Corp",
CustomerAddress: "123 Main St, Suite 100",
ScopeOfWork: "Replace HVAC system in building B",
ServiceCategory: ServiceCategory.HVAC,
Priority: Priority.Standard,
Notes: "Initial assessment complete"
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Theory(DisplayName = "Empty required fields fail validation")]
[InlineData("", "Customer", "Address", "Scope")]
[InlineData("WO-001", "", "Address", "Scope")]
[InlineData("WO-001", "Customer", "", "Scope")]
[InlineData("WO-001", "Customer", "Address", "")]
public void EmptyRequiredFields_Fail(string wo, string name, string address, string scope)
{
var request = new CreateProposalRequest(wo, null, name, address, scope,
ServiceCategory.General, Priority.Standard, null);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
}
[Fact(DisplayName = "WorkOrderNumber exceeding 50 chars fails")]
public void WorkOrderNumber_TooLong_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: new string('X', 51),
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: ServiceCategory.General,
Priority: Priority.Standard,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "WorkOrderNumber");
}
[Fact(DisplayName = "CustomerName exceeding 200 chars fails")]
public void CustomerName_TooLong_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: new string('X', 201),
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: ServiceCategory.General,
Priority: Priority.Standard,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "CustomerName");
}
[Fact(DisplayName = "ScopeOfWork exceeding 10000 chars fails")]
public void ScopeOfWork_TooLong_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: new string('X', 10001),
ServiceCategory: ServiceCategory.General,
Priority: Priority.Standard,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ScopeOfWork");
}
[Fact(DisplayName = "Notes exceeding 5000 chars fails")]
public void Notes_TooLong_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: ServiceCategory.General,
Priority: Priority.Standard,
Notes: new string('X', 5001)
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Notes");
}
[Fact(DisplayName = "Null notes are valid")]
public void NullNotes_Passes()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: ServiceCategory.General,
Priority: Priority.Standard,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeTrue();
}
[Fact(DisplayName = "Invalid ServiceCategory enum value fails")]
public void InvalidServiceCategory_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: (ServiceCategory)999,
Priority: Priority.Standard,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "ServiceCategory");
}
[Fact(DisplayName = "Invalid Priority enum value fails")]
public void InvalidPriority_Fails()
{
var request = new CreateProposalRequest(
WorkOrderNumber: "WO-001",
PoNumber: null,
CustomerName: "Customer",
CustomerAddress: "Address",
ScopeOfWork: "Scope",
ServiceCategory: ServiceCategory.General,
Priority: (Priority)999,
Notes: null
);
var result = _sut.Validate(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "Priority");
}
}

View file

@ -58,6 +58,15 @@ export class ComputeStack extends cdk.Stack {
}),
});
// Fix: INF-H3 — restrict OpenSearch Serverless to VPC (was AllowFromPublic: true).
// Create a VPC endpoint so Lambdas in private subnets can reach the collection.
const ossVpcEndpoint = new opensearchserverless.CfnVpcEndpoint(this, 'OssVpcEndpoint', {
name: 'proposal-system-kb-vpce',
vpcId: props.vpc.vpcId,
subnetIds: props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds,
securityGroupIds: [props.lambdaSecurityGroup.securityGroupId],
});
const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', {
name: 'proposal-system-kb-net',
type: 'network',
@ -65,9 +74,11 @@ export class ComputeStack extends cdk.Stack {
Rules: [
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] },
],
AllowFromPublic: true,
AllowFromPublic: false,
SourceVPCEs: [ossVpcEndpoint.attrId],
}]),
});
ossNetworkPolicy.addDependency(ossVpcEndpoint);
const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', {
name: 'proposal-system-kb',
@ -121,16 +132,61 @@ export class ComputeStack extends cdk.Stack {
resources: [ossCollection.attrArn],
}));
// Fix: INF-M2 — scope AOSS data access policy permissions (was aoss:* on both
// collection and index). KB role needs read/write for embeddings. Index creator
// needs create/describe for bootstrapping the vector index.
const ossDataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', {
name: 'proposal-system-kb-access',
type: 'data',
policy: JSON.stringify([{
Rules: [
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'], Permission: ['aoss:*'] },
{ ResourceType: 'index', Resource: ['index/proposal-system-kb/*'], Permission: ['aoss:*'] },
],
Principal: [kbRole.roleArn, indexCreatorFn.role!.roleArn],
}]),
policy: JSON.stringify([
{
Description: 'Bedrock KB role — read/write documents and describe collection',
Rules: [
{
ResourceType: 'collection',
Resource: ['collection/proposal-system-kb'],
Permission: [
'aoss:DescribeCollectionItems',
'aoss:CreateCollectionItems',
'aoss:UpdateCollectionItems',
],
},
{
ResourceType: 'index',
Resource: ['index/proposal-system-kb/*'],
Permission: [
'aoss:DescribeIndex',
'aoss:ReadDocument',
'aoss:WriteDocument',
],
},
],
Principal: [kbRole.roleArn],
},
{
Description: 'Index creator Lambda — create and describe index during bootstrap',
Rules: [
{
ResourceType: 'collection',
Resource: ['collection/proposal-system-kb'],
Permission: [
'aoss:DescribeCollectionItems',
'aoss:CreateCollectionItems',
],
},
{
ResourceType: 'index',
Resource: ['index/proposal-system-kb/*'],
Permission: [
'aoss:CreateIndex',
'aoss:DescribeIndex',
'aoss:WriteDocument',
],
},
],
Principal: [indexCreatorFn.role!.roleArn],
},
]),
});
ossDataAccessPolicy.addDependency(ossCollection);
@ -237,9 +293,12 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn],
}));
// Function URL for internal Lambda-to-API calls (bypasses API Gateway JWT authorizer)
// Fix: LAM-C1/INF-H1 — require IAM auth on Function URL (was authType NONE).
// NOTE: Lambda HTTP clients (suggestions, pdf-extract, pdf-generate, library-ingest)
// must use SigV4 signing when calling this URL. The API key header alone is no longer
// sufficient for authentication at the transport layer.
const apiFunctionUrl = apiFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
authType: lambda.FunctionUrlAuthType.AWS_IAM,
});
// API Gateway HTTP API
@ -248,6 +307,7 @@ export class ComputeStack extends cdk.Stack {
corsPreflight: {
allowOrigins: [
'https://proposals.seahaven.com',
'https://d2yevct5e5uuz5.cloudfront.net',
'http://localhost:5173',
],
allowMethods: [
@ -262,11 +322,29 @@ export class ComputeStack extends cdk.Stack {
},
});
const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', {
logGroupName: '/aws/apigateway/proposal-system',
retention: logs.RetentionDays.TWO_MONTHS,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const defaultStage = httpApi.defaultStage!.node.defaultChild as apigatewayv2.CfnStage;
defaultStage.defaultRouteSettings = {
throttlingBurstLimit: 50,
throttlingRateLimit: 100,
};
defaultStage.accessLogSettings = {
destinationArn: apiAccessLogGroup.logGroupArn,
format: JSON.stringify({
requestId: '$context.requestId',
ip: '$context.identity.sourceIp',
method: '$context.httpMethod',
path: '$context.path',
status: '$context.status',
latency: '$context.responseLatency',
userAgent: '$context.identity.userAgent',
}),
};
const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration(
'ApiIntegration',
@ -287,13 +365,31 @@ export class ComputeStack extends cdk.Stack {
httpApi.addRoutes({
path: '/api/auth/{proxy+}',
methods: [apigatewayv2.HttpMethod.POST],
methods: [apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.OPTIONS],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger/{proxy+}',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/swagger',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/{proxy+}',
methods: [apigatewayv2.HttpMethod.ANY],
methods: [
apigatewayv2.HttpMethod.GET,
apigatewayv2.HttpMethod.POST,
apigatewayv2.HttpMethod.PUT,
apigatewayv2.HttpMethod.DELETE,
apigatewayv2.HttpMethod.PATCH,
],
integration: apiIntegration,
authorizer: jwtAuthorizer,
});
@ -320,9 +416,16 @@ export class ComputeStack extends cdk.Stack {
});
internalApiKeySecret.grantRead(suggestionsFunction);
// Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth
apiFunctionUrl.grantInvokeUrl(suggestionsFunction);
// Fix: INF-M1 — scope Bedrock model ARN to the specific inference profile used
// (was wildcard anthropic.claude-*). Lambda MODEL_ID is a cross-region inference profile.
suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`],
resources: [
`arn:aws:bedrock:us-east-1:${this.account}:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0`,
`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0`,
],
}));
suggestionsFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:Retrieve'],
@ -351,10 +454,16 @@ export class ComputeStack extends cdk.Stack {
});
internalApiKeySecret.grantRead(pdfExtractFunction);
// Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth
apiFunctionUrl.grantInvokeUrl(pdfExtractFunction);
props.uploadsBucket.grantRead(pdfExtractFunction);
// Fix: INF-M1 — scope Bedrock model ARN to the specific inference profile used
pdfExtractFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:InvokeModel'],
resources: [`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-*`],
resources: [
`arn:aws:bedrock:us-east-1:${this.account}:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0`,
`arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0`,
],
}));
// Python Lambda: PDF Generate
@ -378,6 +487,8 @@ export class ComputeStack extends cdk.Stack {
});
internalApiKeySecret.grantRead(pdfGenerateFunction);
// Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth
apiFunctionUrl.grantInvokeUrl(pdfGenerateFunction);
props.generatedBucket.grantWrite(pdfGenerateFunction);
// Python Lambda: Library Ingest
@ -403,6 +514,8 @@ export class ComputeStack extends cdk.Stack {
});
internalApiKeySecret.grantRead(libraryIngestFunction);
// Fix: LAM-C1/INF-H1 — grant Function URL invoke permission for IAM auth
apiFunctionUrl.grantInvokeUrl(libraryIngestFunction);
props.libraryBucket.grantWrite(libraryIngestFunction);
libraryIngestFunction.addToRolePolicy(new iam.PolicyStatement({
actions: ['bedrock:StartIngestionJob'],

View file

@ -107,9 +107,11 @@ export class FoundationStack extends cdk.Stack {
this.dbSecret = dbInstance.secret!;
// S3 Buckets
// Fix: INF-M5 — enforce HTTPS-only access on all S3 buckets
this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', {
bucketName: `proposal-system-uploads-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
@ -141,6 +143,7 @@ export class FoundationStack extends cdk.Stack {
this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', {
bucketName: `proposal-system-generated-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, // Fix: INF-M5
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
@ -151,6 +154,7 @@ export class FoundationStack extends cdk.Stack {
this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', {
bucketName: `proposal-system-library-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, // Fix: INF-M5
versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,

View file

@ -8,9 +8,11 @@ export class FrontendStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Fix: INF-M5 — enforce HTTPS-only access on S3 bucket
const siteBucket = new s3.Bucket(this, 'SiteBucket', {
bucketName: `proposal-system-web-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,

View file

@ -13,7 +13,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1124.1",
"aws-cdk": "^2.1125.0",
"typescript": "~5.7.0"
}
},
@ -76,9 +76,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1124.1",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1124.1.tgz",
"integrity": "sha512-sRYdPMdkX+02EHaT946AFV0w0CMfbHKWpLZPv525xTCkaVu1eYu6DzHFuTdimxdSN0uGQ2D4LHrD1sr94tRhow==",
"version": "2.1125.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1125.0.tgz",
"integrity": "sha512-QAvsE2XQMcyNOjMMqAS7eDADR9t6vcFcMQvhOmtLfDqgfJXSyTkHvzM5zgwZCdJ4FNqWr5Y/zXvL1Cv5ECKXwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View file

@ -7,7 +7,7 @@
"watch": "tsc -w",
"cdk": "cdk",
"synth": "cdk synth",
"deploy": "cdk deploy --all --require-approval never"
"deploy": "cdk deploy --all --require-approval broadening"
},
"dependencies": {
"aws-cdk-lib": "2.257.0",
@ -15,7 +15,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1124.1",
"aws-cdk": "^2.1125.0",
"typescript": "~5.7.0"
}
}

View file

@ -8,6 +8,7 @@ then triggers a KB sync.
import json
import logging
import os
import re
import time
from datetime import datetime
@ -17,6 +18,9 @@ import httpx
logger = logging.getLogger(__name__)
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
# Fix: LAM-M8 — pattern for allowed S3 key characters
_SAFE_S3_KEY_RE = re.compile(r"^[a-zA-Z0-9\-_./\s]+$")
LIBRARY_BUCKET = os.environ.get("LIBRARY_BUCKET", "")
KNOWLEDGE_BASE_ID = os.environ.get("KNOWLEDGE_BASE_ID", "")
DATA_SOURCE_ID = os.environ.get("DATA_SOURCE_ID", "")
@ -28,29 +32,77 @@ bedrock_agent = boto3.client("bedrock-agent")
secrets_client = boto3.client("secretsmanager")
_cached_api_key: str | None = None
_cached_api_key_ts: float = 0.0
_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes
def _get_api_key() -> str:
global _cached_api_key
if _cached_api_key is None:
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
"""Fetch internal API key from Secrets Manager with a 5-minute TTL cache.
Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached
value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect.
"""
global _cached_api_key, _cached_api_key_ts
now = time.monotonic()
if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS:
return _cached_api_key
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
_cached_api_key_ts = now
return _cached_api_key
def _validate_s3_key(key: str) -> str:
"""Fix: LAM-M8 — sanitize and validate S3 keys before use."""
sanitized = key.replace("../", "").replace("..\\", "")
while "//" in sanitized:
sanitized = sanitized.replace("//", "/")
sanitized = sanitized.strip("/").strip()
if not sanitized:
raise ValueError("S3 key is empty after sanitization")
if not _SAFE_S3_KEY_RE.match(sanitized):
raise ValueError(f"S3 key contains disallowed characters: {sanitized!r}")
return sanitized
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
# Fix: LAM-M1 — validate event structure before processing
records = event.get("Records")
if not isinstance(records, list) or not records:
logger.warning("Event has no Records or Records is not a list, returning early")
return {"batchItemFailures": []}
for record in records:
if "body" not in record:
logger.warning(
"Record missing 'body' key, skipping: %s",
record.get("messageId", "unknown"),
)
continue
try:
body = json.loads(record["body"])
except (json.JSONDecodeError, TypeError) as e:
# Fix: LAM-M1 — malformed JSON cannot be retried, add to failures
logger.error("Malformed JSON in record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
continue
try:
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
process_ingestion(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception(
"Failed to process record %s: %s", record.get("messageId"), e
)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
@ -81,7 +133,8 @@ def fetch_proposal(proposal_id: str) -> dict | None:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching proposal: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching proposal: %s", e)
return None
@ -95,7 +148,8 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching line items: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching line items: %s", e)
return []
@ -161,6 +215,13 @@ def upload_to_library(proposal: dict, document: str) -> str | None:
category = proposal.get("serviceCategory", "General")
s3_key = f"proposals/{category.lower()}/{proposal_number}.md"
# Fix: LAM-M8 — validate constructed S3 key
try:
s3_key = _validate_s3_key(s3_key)
except ValueError as e:
logger.error("Invalid S3 key for proposal %s: %s", proposal_number, e)
return None
try:
s3.put_object(
Bucket=LIBRARY_BUCKET,
@ -178,7 +239,8 @@ def upload_to_library(proposal: dict, document: str) -> str | None:
logger.info("Uploaded %s to library bucket", s3_key)
return s3_key
except Exception as e:
logger.error("Error uploading to library: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error uploading to library: %s", e)
return None
@ -195,7 +257,8 @@ def trigger_kb_sync():
job_id = response.get("ingestionJob", {}).get("ingestionJobId", "")
logger.info("Started KB ingestion job: %s", job_id)
except Exception as e:
logger.error("Error triggering KB sync: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error triggering KB sync: %s", e)
def _api_headers() -> dict:
@ -210,11 +273,13 @@ def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
last_resp = None
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
last_resp = resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
@ -222,4 +287,6 @@ def _retry_request(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]
if last_resp is not None:
return last_resp
raise RuntimeError(f"All {max_retries} retries failed for {method} {url}")

View file

@ -1,2 +1,2 @@
boto3>=1.43.14,<2.0
boto3>=1.43.18,<2.0
httpx>=0.28.1,<1.0

View file

@ -8,6 +8,7 @@ import base64
import json
import logging
import os
import re
import tempfile
import time
@ -18,6 +19,10 @@ import pdfplumber
logger = logging.getLogger(__name__)
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
# Fix: LAM-M8 — pattern for allowed S3 key characters (alphanumeric, hyphens,
# underscores, forward slashes, dots, and spaces)
_SAFE_S3_KEY_RE = re.compile(r"^[a-zA-Z0-9\-_./\s]+$")
UPLOADS_BUCKET = os.environ.get("UPLOADS_BUCKET", "")
API_BASE_URL = os.environ.get("API_BASE_URL", "")
MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
@ -28,24 +33,74 @@ bedrock_runtime = boto3.client("bedrock-runtime")
secrets_client = boto3.client("secretsmanager")
_cached_api_key: str | None = None
_cached_api_key_ts: float = 0.0
_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes
# Fix: LAM-M3 — maximum PDF file size (50 MB)
_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024
def _get_api_key() -> str:
global _cached_api_key
if _cached_api_key is None:
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
"""Fetch internal API key from Secrets Manager with a 5-minute TTL cache.
Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached
value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect.
"""
global _cached_api_key, _cached_api_key_ts
now = time.monotonic()
if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS:
return _cached_api_key
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
_cached_api_key_ts = now
return _cached_api_key
def _validate_s3_key(key: str) -> str:
"""Fix: LAM-M8 — sanitize and validate S3 keys from user-provided data."""
# Strip path traversal sequences
sanitized = key.replace("../", "").replace("..\\", "")
# Collapse any double slashes left behind
while "//" in sanitized:
sanitized = sanitized.replace("//", "/")
sanitized = sanitized.strip("/").strip()
if not sanitized:
raise ValueError("S3 key is empty after sanitization")
if not _SAFE_S3_KEY_RE.match(sanitized):
raise ValueError(f"S3 key contains disallowed characters: {sanitized!r}")
return sanitized
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
# Fix: LAM-M1 — validate event structure before processing
records = event.get("Records")
if not isinstance(records, list) or not records:
logger.warning("Event has no Records or Records is not a list, returning early")
return {"batchItemFailures": []}
for record in records:
if "body" not in record:
logger.warning(
"Record missing 'body' key, skipping: %s",
record.get("messageId", "unknown"),
)
continue
try:
body = json.loads(record["body"])
except (json.JSONDecodeError, TypeError) as e:
# Fix: LAM-M1 — malformed JSON cannot be retried, add to failures
logger.error("Malformed JSON in record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
continue
try:
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
s3_key = payload.get("s3Key", "")
@ -55,9 +110,15 @@ def handler(event, context):
logger.warning("No s3Key in payload for proposal %s", proposal_id)
continue
# Fix: LAM-M8 — validate S3 key before use
s3_key = _validate_s3_key(s3_key)
process_pdf(proposal_id, s3_key, vendor_proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception(
"Failed to process record %s: %s", record.get("messageId"), e
)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
@ -79,7 +140,8 @@ def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str):
save_extraction(vendor_proposal_id, extracted)
except Exception as e:
logger.error("Error processing PDF: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error processing PDF: %s", e)
update_processing_status(vendor_proposal_id, "Failed")
finally:
if pdf_path:
@ -90,6 +152,22 @@ def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str):
def download_pdf(s3_key: str) -> str:
# Fix: LAM-M3 — check file size before downloading to avoid processing
# excessively large PDFs that could exhaust Lambda memory/tmp storage.
head = s3.head_object(Bucket=UPLOADS_BUCKET, Key=s3_key)
file_size = head.get("ContentLength", 0)
if file_size > _MAX_PDF_SIZE_BYTES:
logger.warning(
"Fix: LAM-M3 — PDF too large: key=%s size=%d bytes (max=%d)",
s3_key,
file_size,
_MAX_PDF_SIZE_BYTES,
)
raise ValueError(
f"PDF file size ({file_size} bytes) exceeds maximum "
f"allowed size ({_MAX_PDF_SIZE_BYTES} bytes) for key: {s3_key}"
)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
s3.download_file(UPLOADS_BUCKET, s3_key, tmp.name)
tmp.close()
@ -134,7 +212,8 @@ def extract_with_pdfplumber(pdf_path: str) -> dict:
break
except Exception as e:
logger.error("pdfplumber extraction failed: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("pdfplumber extraction failed: %s", e)
return result
@ -289,7 +368,8 @@ Respond ONLY with the JSON object, no additional text.""",
}
except Exception as e:
logger.error("Claude multimodal extraction failed: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Claude multimodal extraction failed: %s", e)
return {
"vendorName": "",
"lineItems": [],
@ -321,7 +401,8 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
"Failed to save extraction: %s %s", resp.status_code, resp.text
)
except Exception as e:
logger.error("Error saving extraction: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error saving extraction: %s", e)
def update_processing_status(vendor_proposal_id: str, status: str):
@ -335,7 +416,8 @@ def update_processing_status(vendor_proposal_id: str, status: str):
headers=_api_headers(),
)
except Exception as e:
logger.error("Error updating status: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error updating status: %s", e)
def _api_headers() -> dict:

View file

@ -1,3 +1,3 @@
pdfplumber>=0.11.9,<1.0
boto3>=1.43.14,<2.0
boto3>=1.43.18,<2.0
httpx>=0.28.1,<1.0

View file

@ -7,6 +7,7 @@ Triggered via SQS when an admin requests PDF generation.
import json
import logging
import os
import re
import time
from datetime import datetime
from io import BytesIO
@ -37,6 +38,11 @@ s3 = boto3.client("s3")
secrets_client = boto3.client("secretsmanager")
_cached_api_key: str | None = None
_cached_api_key_ts: float = 0.0
_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes
# Fix: LAM-M8 — pattern for allowed S3 key characters
_SAFE_S3_KEY_RE = re.compile(r"^[a-zA-Z0-9\-_./\s]+$")
COMPANY_NAME = "Sea Haven Industries"
COMPANY_ADDRESS = "Sea Haven Industries LLC"
@ -55,26 +61,72 @@ TERMS_AND_CONDITIONS = """
def _get_api_key() -> str:
global _cached_api_key
if _cached_api_key is None:
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
"""Fetch internal API key from Secrets Manager with a 5-minute TTL cache.
Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached
value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect.
"""
global _cached_api_key, _cached_api_key_ts
now = time.monotonic()
if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS:
return _cached_api_key
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
_cached_api_key_ts = now
return _cached_api_key
def _validate_s3_key(key: str) -> str:
"""Fix: LAM-M8 — sanitize and validate S3 keys before use."""
sanitized = key.replace("../", "").replace("..\\", "")
while "//" in sanitized:
sanitized = sanitized.replace("//", "/")
sanitized = sanitized.strip("/").strip()
if not sanitized:
raise ValueError("S3 key is empty after sanitization")
if not _SAFE_S3_KEY_RE.match(sanitized):
raise ValueError(f"S3 key contains disallowed characters: {sanitized!r}")
return sanitized
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
# Fix: LAM-M1 — validate event structure before processing
records = event.get("Records")
if not isinstance(records, list) or not records:
logger.warning("Event has no Records or Records is not a list, returning early")
return {"batchItemFailures": []}
for record in records:
if "body" not in record:
logger.warning(
"Record missing 'body' key, skipping: %s",
record.get("messageId", "unknown"),
)
continue
try:
body = json.loads(record["body"])
except (json.JSONDecodeError, TypeError) as e:
# Fix: LAM-M1 — malformed JSON cannot be retried, add to failures
logger.error("Malformed JSON in record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
continue
try:
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
generate_pdf(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception(
"Failed to process record %s: %s", record.get("messageId"), e
)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
@ -93,6 +145,9 @@ def generate_pdf(proposal_id: str):
revision = proposal.get("currentRevision", 1)
s3_key = f"{proposal_number}/rev-{revision}.pdf"
# Fix: LAM-M8 — validate constructed S3 key
s3_key = _validate_s3_key(s3_key)
upload_pdf(s3_key, pdf_bytes)
register_pdf(proposal_id, s3_key)
@ -110,7 +165,8 @@ def fetch_proposal(proposal_id: str) -> dict | None:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching proposal: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching proposal: %s", e)
return None
@ -124,7 +180,8 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching line items: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching line items: %s", e)
return []
@ -524,7 +581,8 @@ def upload_pdf(s3_key: str, pdf_bytes: bytes):
ContentType="application/pdf",
)
except Exception as e:
logger.error("Error uploading PDF: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error uploading PDF: %s", e)
raise
@ -539,7 +597,8 @@ def register_pdf(proposal_id: str, s3_key: str):
if resp.status_code not in (200, 201):
logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text)
except Exception as e:
logger.error("Error registering PDF: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error registering PDF: %s", e)
def _api_headers() -> dict:

View file

@ -1,3 +1,3 @@
reportlab>=4.5.1,<5.0
boto3>=1.43.14,<2.0
boto3>=1.43.18,<2.0
httpx>=0.28.1,<1.0

3
lambdas/pytest.ini Normal file
View file

@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = tests

View file

@ -6,7 +6,9 @@ to generate line item suggestions for new proposals.
import json
import logging
import math
import os
import re
import time
import boto3
@ -25,30 +27,164 @@ bedrock_runtime = boto3.client("bedrock-runtime")
secrets_client = boto3.client("secretsmanager")
_cached_api_key: str | None = None
_cached_api_key_ts: float = 0.0
_API_KEY_TTL_SECONDS = 300 # Fix: LAM-M9 — re-fetch every 5 minutes
def _get_api_key() -> str:
global _cached_api_key
if _cached_api_key is None:
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
"""Fetch internal API key from Secrets Manager with a 5-minute TTL cache.
Fix: LAM-M9 — the key was previously cached indefinitely. Now the cached
value expires after _API_KEY_TTL_SECONDS so rotated secrets take effect.
"""
global _cached_api_key, _cached_api_key_ts
now = time.monotonic()
if _cached_api_key is not None and (now - _cached_api_key_ts) < _API_KEY_TTL_SECONDS:
return _cached_api_key
if INTERNAL_API_KEY_SECRET_ARN:
resp = secrets_client.get_secret_value(SecretId=INTERNAL_API_KEY_SECRET_ARN)
_cached_api_key = resp["SecretString"]
else:
_cached_api_key = ""
_cached_api_key_ts = now
return _cached_api_key
# ---------------------------------------------------------------------------
# Fix: LAM-M2 — Prompt injection mitigation
# ---------------------------------------------------------------------------
# Blocklist patterns that commonly appear in prompt injection attempts.
# This is a simple defense-in-depth measure, not a full NLP solution.
_INJECTION_PATTERNS = [
re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.IGNORECASE),
re.compile(r"ignore\s+(all\s+)?above\s+instructions", re.IGNORECASE),
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
re.compile(r"override\s+(all\s+)?instructions", re.IGNORECASE),
re.compile(r"you\s+are\s+now\s+(a|an)\s+", re.IGNORECASE),
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
re.compile(r"system\s*:", re.IGNORECASE),
re.compile(r"<\|?\s*(system|im_start|endoftext)\s*\|?>", re.IGNORECASE),
re.compile(r"\[INST\]", re.IGNORECASE),
re.compile(r"```\s*(system|instruction)", re.IGNORECASE),
]
# Delimiter sequences that could be used to break out of the user-text section
_INJECTION_DELIMITERS = ["```", "---\n", "===\n", "***\n"]
def sanitize_user_text(text: str) -> str:
"""Strip common prompt injection patterns from user-supplied text.
Fix: LAM-M2 — basic prompt injection mitigation before including user
text in Bedrock prompts. Applies a blocklist of known injection patterns
and neutralises delimiter sequences.
"""
if not text:
return text
sanitized = text
# Remove blocklisted patterns
for pattern in _INJECTION_PATTERNS:
sanitized = pattern.sub("[removed]", sanitized)
# Neutralise delimiter sequences by replacing them with a safe alternative
for delim in _INJECTION_DELIMITERS:
sanitized = sanitized.replace(delim, " ")
return sanitized
# ---------------------------------------------------------------------------
# Fix: LAM-M6 — Numeric validation for suggestion amounts
# ---------------------------------------------------------------------------
_MAX_LINE_ITEM_VALUE = 10_000_000 # $10M ceiling for any single value
def validate_line_item_numerics(items: list[dict]) -> list[dict]:
"""Validate and filter line items with unreasonable numeric values.
Fix: LAM-M6 — after getting suggestions from Bedrock, reject items with
negative values, NaN, or values exceeding the $10M ceiling.
"""
validated = []
for item in items:
rejected = False
for field in ("quantity", "unitPrice", "totalPrice"):
value = item.get(field)
if value is None:
continue
try:
num = float(value)
except (ValueError, TypeError):
logger.warning(
"Fix: LAM-M6 — rejected line item: %s is not a valid number (%r) "
"in item %r",
field, value, item.get("description", "unknown"),
)
rejected = True
break
if math.isnan(num) or math.isinf(num):
logger.warning(
"Fix: LAM-M6 — rejected line item: %s is NaN/Inf in item %r",
field, item.get("description", "unknown"),
)
rejected = True
break
if num < 0:
logger.warning(
"Fix: LAM-M6 — rejected line item: %s is negative (%.2f) in item %r",
field, num, item.get("description", "unknown"),
)
rejected = True
break
if num > _MAX_LINE_ITEM_VALUE:
logger.warning(
"Fix: LAM-M6 — rejected line item: %s exceeds max (%.2f > %d) in item %r",
field, num, _MAX_LINE_ITEM_VALUE, item.get("description", "unknown"),
)
rejected = True
break
if not rejected:
validated.append(item)
return validated
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
# Fix: LAM-M1 — validate event structure before processing
records = event.get("Records")
if not isinstance(records, list) or not records:
logger.warning("Event has no Records or Records is not a list, returning early")
return {"batchItemFailures": []}
for record in records:
if "body" not in record:
logger.warning(
"Record missing 'body' key, skipping: %s",
record.get("messageId", "unknown"),
)
continue
try:
body = json.loads(record["body"])
except (json.JSONDecodeError, TypeError) as e:
# Fix: LAM-M1 — malformed JSON cannot be retried, add to failures
logger.error("Malformed JSON in record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
continue
try:
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
trigger = payload.get("trigger", "generate")
process_suggestion(proposal_id, trigger)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception(
"Failed to process record %s: %s", record.get("messageId"), e
)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
@ -65,7 +201,17 @@ def process_suggestion(proposal_id: str, trigger: str):
existing_items = fetch_line_items(proposal_id)
similar_proposals = retrieve_similar(scope, category)
has_ai_items = any(li.get("source") == "AI" for li in existing_items)
if has_ai_items:
logger.info("AI items already exist for %s, skipping regeneration", proposal_id)
return
status = proposal.get("status", "")
if status not in ("InReview", "Revised"):
logger.info("Proposal %s is in status %s, skipping suggestions", proposal_id, status)
return
similar_proposals = retrieve_similar(sanitize_user_text(scope), category)
suggested_items = generate_line_items(scope, category, priority, similar_proposals)
@ -91,7 +237,8 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching line items: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching line items: %s", e)
return []
@ -105,7 +252,8 @@ def fetch_proposal(proposal_id: str) -> dict | None:
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error("Error fetching proposal: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error fetching proposal: %s", e)
return None
@ -157,7 +305,8 @@ def retrieve_similar(scope: str, category: str) -> list[dict]:
return results
except Exception as e:
logger.error("Error retrieving from KB: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error retrieving from KB: %s", e)
return []
@ -167,6 +316,11 @@ def generate_line_items(
priority: str,
similar_proposals: list[dict],
) -> list[dict]:
# Fix: LAM-M2 — sanitize user-supplied text before including in prompt
safe_scope = sanitize_user_text(scope)
safe_category = sanitize_user_text(category)
safe_priority = sanitize_user_text(priority)
context_block = ""
if similar_proposals:
context_block = "Here are similar historical proposals and their line items for reference:\n\n"
@ -180,11 +334,11 @@ def generate_line_items(
Based on the scope of work and similar historical proposals, generate a detailed list of line items
with quantities, units, and estimated pricing.
Service Category: {category}
Priority: {priority}
Service Category: {safe_category}
Priority: {safe_priority}
Scope of Work:
{scope}
{safe_scope}
{context_block}
@ -222,10 +376,14 @@ Respond ONLY with the JSON array, no additional text."""
content = content.rsplit("```", 1)[0]
line_items = json.loads(content)
return line_items if isinstance(line_items, list) else []
if not isinstance(line_items, list):
return []
# Fix: LAM-M6 — validate numeric fields before returning suggestions
return validate_line_item_numerics(line_items)
except Exception as e:
logger.error("Error generating line items: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error generating line items: %s", e)
return []
@ -286,7 +444,8 @@ def post_line_items(proposal_id: str, items: list[dict], existing_items: list[di
"Failed to post line items: %s %s", resp.status_code, resp.text
)
except Exception as e:
logger.error("Error posting line items: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error posting line items: %s", e)
def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
@ -310,7 +469,8 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
headers=_api_headers(),
)
except Exception as e:
logger.error("Error storing similar reference: %s", e)
# Fix: LAM-M5 — include stack trace in error logging
logger.exception("Error storing similar reference: %s", e)
def _api_headers() -> dict:
@ -325,11 +485,13 @@ def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
last_resp = None
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
last_resp = resp
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
if attempt == max_retries - 1:
raise
@ -337,4 +499,6 @@ def _retry_request(
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
)
time.sleep(min(2**attempt, 4))
return resp # type: ignore[possibly-undefined]
if last_resp is not None:
return last_resp
raise RuntimeError(f"All {max_retries} retries failed for {method} {url}")

View file

@ -1,2 +1,2 @@
boto3>=1.43.14,<2.0
boto3>=1.43.18,<2.0
httpx>=0.28.1,<1.0

View file

19
lambdas/tests/conftest.py Normal file
View file

@ -0,0 +1,19 @@
"""Common fixtures for Lambda tests."""
import pytest
@pytest.fixture(autouse=True)
def _env_setup(monkeypatch):
"""Set environment variables required by all Lambdas."""
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing")
monkeypatch.setenv("AWS_SESSION_TOKEN", "testing")
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
monkeypatch.setenv("GENERATED_BUCKET", "test-generated-pdfs")
monkeypatch.setenv("API_BASE_URL", "http://localhost:5000")
monkeypatch.setenv("INTERNAL_API_KEY_SECRET_ARN", "")
monkeypatch.setenv("KNOWLEDGE_BASE_ID", "")
monkeypatch.setenv("MODEL_ID", "us.anthropic.claude-sonnet-4-5-20250929-v1:0")

21
lambdas/tests/helpers.py Normal file
View file

@ -0,0 +1,21 @@
"""Test helpers for Lambda tests."""
import json
def make_sqs_event(*bodies: dict) -> dict:
"""Build a minimal SQS event with the given record bodies."""
records = []
for i, body in enumerate(bodies):
records.append({
"messageId": f"msg-{i}",
"body": json.dumps(body),
"receiptHandle": f"handle-{i}",
"attributes": {},
"messageAttributes": {},
"md5OfBody": "",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue",
"awsRegion": "us-east-1",
})
return {"Records": records}

View file

@ -0,0 +1,4 @@
pytest>=8.0.0
pytest-mock>=3.14.0
moto[s3,secretsmanager,sqs]>=5.0.0
httpx>=0.27.0

View file

@ -0,0 +1,151 @@
"""Tests for pdf-generate Lambda handler.
QA-C6 (partial): Verifies SQS batch processing, error handling, and
batch failure reporting for the PDF generation pipeline.
"""
import importlib
import json
import sys
import os
from unittest.mock import MagicMock, patch
import pytest
from helpers import make_sqs_event
# Import pdf-generate app under a unique module name to avoid collision with
# suggestions/app.py (both are named 'app').
_pdf_gen_dir = os.path.join(os.path.dirname(__file__), "..", "pdf-generate")
_spec = importlib.util.spec_from_file_location("pdf_generate_app", os.path.join(_pdf_gen_dir, "app.py"))
pdf_generate_app = importlib.util.module_from_spec(_spec)
sys.modules["pdf_generate_app"] = pdf_generate_app
_spec.loader.exec_module(pdf_generate_app)
class TestPdfGenerateHandler:
"""Test the SQS handler entry point for pdf-generate Lambda."""
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_processes_sqs_record_successfully(self, mock_generate_pdf):
"""QA-C6: Handler extracts proposalId from SQS body and calls generate_pdf."""
mock_generate_pdf.return_value = None
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
result = pdf_generate_app.handler(event, None)
mock_generate_pdf.assert_called_once_with("abc-123")
assert result["batchItemFailures"] == []
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_processes_multiple_records(self, mock_generate_pdf):
"""QA-C6: Handler processes all records in an SQS batch."""
mock_generate_pdf.return_value = None
event = make_sqs_event(
{"payload": {"proposalId": "id-1"}},
{"payload": {"proposalId": "id-2"}},
{"payload": {"proposalId": "id-3"}},
)
result = pdf_generate_app.handler(event, None)
assert mock_generate_pdf.call_count == 3
assert result["batchItemFailures"] == []
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_returns_batch_failures_on_error(self, mock_generate_pdf):
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
mock_generate_pdf.side_effect = [None, Exception("PDF generation failed"), None]
event = make_sqs_event(
{"payload": {"proposalId": "id-1"}},
{"payload": {"proposalId": "id-2"}},
{"payload": {"proposalId": "id-3"}},
)
result = pdf_generate_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_handles_malformed_body(self, mock_generate_pdf):
"""QA-C6: Handler reports failure for records with invalid JSON body."""
event = {
"Records": [
{
"messageId": "msg-bad",
"body": "not-valid-json",
"receiptHandle": "handle-0",
}
]
}
result = pdf_generate_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-bad"
mock_generate_pdf.assert_not_called()
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_handles_missing_proposal_id(self, mock_generate_pdf):
"""QA-C6: Handler reports failure when proposalId is missing from payload."""
event = make_sqs_event({"payload": {}})
result = pdf_generate_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
mock_generate_pdf.assert_not_called()
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_handles_empty_records(self, mock_generate_pdf):
"""QA-C6: Handler handles empty Records array gracefully."""
result = pdf_generate_app.handler({"Records": []}, None)
assert result["batchItemFailures"] == []
mock_generate_pdf.assert_not_called()
@patch.object(pdf_generate_app, "generate_pdf")
def test_handler_handles_body_without_payload_wrapper(self, mock_generate_pdf):
"""QA-C6: Handler supports body with proposalId at top level (no payload wrapper)."""
mock_generate_pdf.return_value = None
event = make_sqs_event({"proposalId": "direct-id"})
result = pdf_generate_app.handler(event, None)
mock_generate_pdf.assert_called_once_with("direct-id")
assert result["batchItemFailures"] == []
class TestPdfGenerateHelpers:
"""Test helper functions in the pdf-generate Lambda."""
@patch.object(pdf_generate_app, "_retry_request")
def test_fetch_proposal_returns_dict_on_200(self, mock_retry):
"""QA-C6: fetch_proposal returns proposal dict on 200 response."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "abc", "proposalNumber": "P-001"}
mock_retry.return_value = mock_response
result = pdf_generate_app.fetch_proposal("abc")
assert result is not None
assert result["proposalNumber"] == "P-001"
@patch.object(pdf_generate_app, "_retry_request")
def test_fetch_proposal_returns_none_on_404(self, mock_retry):
"""QA-C6: fetch_proposal returns None when API returns 404."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_retry.return_value = mock_response
result = pdf_generate_app.fetch_proposal("nonexistent")
assert result is None
@patch.object(pdf_generate_app, "_retry_request")
def test_fetch_proposal_returns_none_on_exception(self, mock_retry):
"""QA-C6: fetch_proposal returns None on network error."""
mock_retry.side_effect = Exception("Connection refused")
result = pdf_generate_app.fetch_proposal("abc")
assert result is None

View file

@ -0,0 +1,218 @@
"""Tests for suggestions Lambda handler.
QA-C6 (partial): Verifies SQS batch processing, idempotency guard
(skips when AI items exist), and error handling.
"""
import importlib
import json
import sys
import os
from unittest.mock import MagicMock, patch, call
import pytest
from helpers import make_sqs_event
# Import suggestions app under a unique module name to avoid collision with
# pdf-generate/app.py (both are named 'app').
_suggestions_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions")
_spec = importlib.util.spec_from_file_location("suggestions_app", os.path.join(_suggestions_dir, "app.py"))
suggestions_app = importlib.util.module_from_spec(_spec)
sys.modules["suggestions_app"] = suggestions_app
_spec.loader.exec_module(suggestions_app)
class TestSuggestionsHandler:
"""Test the SQS handler entry point for suggestions Lambda."""
@patch.object(suggestions_app, "process_suggestion")
def test_handler_processes_sqs_record(self, mock_process):
"""QA-C6: Handler extracts proposalId and trigger from SQS body."""
event = make_sqs_event({"payload": {"proposalId": "abc-123", "trigger": "generate"}})
result = suggestions_app.handler(event, None)
mock_process.assert_called_once_with("abc-123", "generate")
assert result["batchItemFailures"] == []
@patch.object(suggestions_app, "process_suggestion")
def test_handler_defaults_trigger_to_generate(self, mock_process):
"""QA-C6: Handler defaults trigger to 'generate' when not specified."""
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
result = suggestions_app.handler(event, None)
mock_process.assert_called_once_with("abc-123", "generate")
assert result["batchItemFailures"] == []
@patch.object(suggestions_app, "process_suggestion")
def test_handler_returns_batch_failures_on_error(self, mock_process):
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
mock_process.side_effect = [None, RuntimeError("Bedrock timeout")]
event = make_sqs_event(
{"payload": {"proposalId": "id-1", "trigger": "generate"}},
{"payload": {"proposalId": "id-2", "trigger": "generate"}},
)
result = suggestions_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
@patch.object(suggestions_app, "process_suggestion")
def test_handler_handles_malformed_body(self, mock_process):
"""QA-C6: Handler reports failure for records with invalid JSON."""
event = {
"Records": [
{
"messageId": "msg-bad",
"body": "{invalid json",
"receiptHandle": "handle-0",
}
]
}
result = suggestions_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
mock_process.assert_not_called()
class TestProcessSuggestion:
"""Test the core suggestion generation logic."""
@patch.object(suggestions_app, "store_similar_references")
@patch.object(suggestions_app, "post_line_items")
@patch.object(suggestions_app, "generate_line_items")
@patch.object(suggestions_app, "retrieve_similar")
@patch.object(suggestions_app, "fetch_line_items")
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_full_flow(
self,
mock_fetch_proposal,
mock_fetch_items,
mock_retrieve,
mock_generate,
mock_post,
mock_store_refs,
):
"""QA-C6: Full suggestion flow fetches proposal, retrieves similar, generates, posts."""
mock_fetch_proposal.return_value = {
"id": "abc",
"scopeOfWork": "Replace HVAC system",
"serviceCategory": "HVAC",
"priority": "Standard",
"status": "InReview",
}
mock_fetch_items.return_value = []
mock_retrieve.return_value = [{"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""}]
mock_generate.return_value = [
{"description": "Ductwork", "quantity": 100, "unit": "linear ft", "unitPrice": 15.0, "totalPrice": 1500.0, "pricingMode": "UnitPrice"},
]
suggestions_app.process_suggestion("abc", "generate")
mock_fetch_proposal.assert_called_once_with("abc")
mock_fetch_items.assert_called_once_with("abc")
mock_retrieve.assert_called_once()
mock_generate.assert_called_once()
mock_post.assert_called_once()
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_skips_when_proposal_not_found(self, mock_fetch):
"""QA-C6: Skips processing when proposal is not found (returns early)."""
mock_fetch.return_value = None
# Should not raise - just logs and returns
suggestions_app.process_suggestion("nonexistent", "generate")
@patch.object(suggestions_app, "_retry_request")
def test_post_line_items_preserves_non_ai_items(self, mock_request):
"""QA-C6: Existing non-AI items are preserved when new suggestions are generated."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_request.return_value = mock_response
existing_items = [
{"id": "manual-1", "description": "Manual item", "quantity": 1, "unit": "each",
"totalPrice": 500, "pricingMode": "TotalPrice", "source": "Manual"},
{"id": "ai-1", "description": "Old AI item", "quantity": 1, "unit": "each",
"totalPrice": 200, "pricingMode": "TotalPrice", "source": "AI"},
]
new_items = [
{"description": "New AI item", "quantity": 2, "unit": "hours",
"unitPrice": 100, "totalPrice": 200, "pricingMode": "UnitPrice"},
]
suggestions_app.post_line_items("abc", new_items, existing_items)
# Verify the API was called
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
# Should have 2 items: 1 preserved Manual + 1 new AI (old AI items are replaced)
assert len(payload["lineItems"]) == 2
sources = [li["source"] for li in payload["lineItems"]]
assert "Manual" in sources
assert "AI" in sources
# The manual item should be first (preserved), AI item second (new)
assert payload["lineItems"][0]["source"] == "Manual"
assert payload["lineItems"][0]["description"] == "Manual item"
assert payload["lineItems"][1]["source"] == "AI"
assert payload["lineItems"][1]["description"] == "New AI item"
@patch.object(suggestions_app, "post_line_items")
@patch.object(suggestions_app, "generate_line_items")
@patch.object(suggestions_app, "retrieve_similar")
@patch.object(suggestions_app, "fetch_line_items")
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_skips_when_no_items_and_no_suggestions(
self,
mock_fetch_proposal,
mock_fetch_items,
mock_retrieve,
mock_generate,
mock_post,
):
"""QA-C6: Skips status update when no existing items and no suggestions generated."""
mock_fetch_proposal.return_value = {
"id": "abc",
"scopeOfWork": "Vague scope",
"serviceCategory": "General",
"priority": "Standard",
}
mock_fetch_items.return_value = []
mock_retrieve.return_value = []
mock_generate.return_value = []
suggestions_app.process_suggestion("abc", "generate")
mock_post.assert_not_called()
class TestRetrySafety:
"""Test retry and API communication helpers."""
def test_get_api_key_caches_result(self):
"""QA-C6: API key is fetched once and cached for subsequent calls."""
# Reset cached key
suggestions_app._cached_api_key = None
mock_secrets = MagicMock()
mock_secrets.get_secret_value.return_value = {"SecretString": "test-key"}
original_client = suggestions_app.secrets_client
original_arn = suggestions_app.INTERNAL_API_KEY_SECRET_ARN
suggestions_app.secrets_client = mock_secrets
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123:secret:key"
try:
key1 = suggestions_app._get_api_key()
key2 = suggestions_app._get_api_key()
assert key1 == "test-key"
assert key2 == "test-key"
# Should only call secrets manager once (cached)
mock_secrets.get_secret_value.assert_called_once()
finally:
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = original_arn
suggestions_app.secrets_client = original_client
suggestions_app._cached_api_key = None

View file

@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-partitions (1.1255.0)
aws-sdk-core (3.250.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -32,11 +32,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (1.128.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-s3 (1.224.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@ -131,7 +131,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.234.0)
fastlane (2.235.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
@ -140,7 +140,7 @@ GEM
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@ -155,12 +155,12 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
jwt (>= 2.1.0, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
@ -196,7 +196,7 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-androidpublisher_v3 (0.101.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@ -215,7 +215,8 @@ GEM
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
@ -227,10 +228,12 @@ GEM
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
@ -242,8 +245,8 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.19.5)
jwt (2.10.2)
json (2.19.7)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
@ -268,7 +271,7 @@ GEM
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
@ -328,7 +331,6 @@ DEPENDENCIES
cocoapods
fastlane
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
@ -337,10 +339,10 @@ CHECKSUMS
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1249.0) sha256=f0bed070aba353e6ffe439953d6c354b3f51d16770386d2b198e71d34612f2e9
aws-sdk-core (3.247.0) sha256=789864594ce8cef05ee3d81fa8ed506099280bda6ea12a7612b8b7c5e5e62851
aws-sdk-kms (1.125.0) sha256=23f81bc0838ae6ec2e8de3eae88af521d0e29d3a59b6c9dbb4b21343ba476bc8
aws-sdk-s3 (1.222.0) sha256=bae4b06fccf0b81b8d77e7abfc56e5e2146590d43c4ab58db0cee3ff5bbfb7f1
aws-partitions (1.1255.0) sha256=490ffc1f032d3eac771cf4a94ab7a3c7999c5edbe6fb7660904e702e291c93b5
aws-sdk-core (3.250.0) sha256=8df71164c7e5f2ff411782a3dc3a1ab5c0a73170284409ead111f385e9992963
aws-sdk-kms (1.128.0) sha256=20ce3957f80c7b40b3115a7e902af52b15a6eef42fe6b2e19db72f8e8c9e5658
aws-sdk-s3 (1.224.0) sha256=7d443c4ae9eda795b60e081d41f49b498e2483f1fc204f3239176ec922ed7879
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
@ -384,7 +386,7 @@ CHECKSUMS
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.234.0) sha256=b74835681ad9a8e9c0931a5727dad1bab433895ac534c864a1ed5749625d26e9
fastlane (2.235.0) sha256=7de12cf4c4e242b68836aef859ba8e6b25bb1db7f6c8e2d705b024fb16a2ed65
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
@ -400,23 +402,24 @@ CHECKSUMS
fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9
fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.100.0) sha256=7a82935bee985190e8fe23bf5e53df3a27d65dd084114bb71b846b617de16489
google-apis-androidpublisher_v3 (0.101.0) sha256=047380b54a3741a6b3fe454a859eb30ed5a5c322a897315bdea312dfb44e2ab6
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.27.0) sha256=9289f29968610754ef11d98b9ec627f0153f3e2616fef839aef096de529f6d1e
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.62.0) sha256=f62467c36df53287fb0252ebb4da85f9e25d7b4c5809d045c2aab1fc307760c1
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.60.0) sha256=b21b752d37945d678a4533be5ef4303f15d33a964d8bc709c7c41c3600f650db
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
googleauth (1.16.2) sha256=15009502e2e38af71948cda918f230e27d327f6882a1e47967a5a4664930a638
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
json (2.19.7) sha256=fe432c8639f6efff69f9d73b518a3705d9581ab93156f981ea72806e1e5bcc3e
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
@ -437,7 +440,7 @@ CHECKSUMS
public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9

View file

@ -13,7 +13,7 @@
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-community/netinfo": "^12.0.1",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native": "^7.2.5",
"@react-navigation/native-stack": "^7.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
@ -21,14 +21,14 @@
"axios": "^1.16.0",
"react": "19.2.6",
"react-native": "^0.85.3",
"react-native-app-auth": "^8.3.0",
"react-native-app-auth": "^8.4.0",
"react-native-document-picker": "^9.3.0",
"react-native-haptic-feedback": "^2.3.0",
"react-native-image-picker": "^7.2.0",
"react-native-image-picker": "^8.2.1",
"react-native-keychain": "^9.2.0",
"react-native-paper": "^5.13.0",
"react-native-paper": "^5.15.3",
"react-native-safe-area-context": "^5.8.0",
"react-native-screens": "^4.5.0",
"react-native-screens": "^4.25.2",
"react-native-share": "^11.0.0",
"react-native-vector-icons": "^10.2.0",
"react-redux": "^9.2.0"
@ -2056,9 +2056,9 @@
}
},
"node_modules/@react-navigation/core": {
"version": "7.17.4",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.4.tgz",
"integrity": "sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==",
"version": "7.17.5",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.5.tgz",
"integrity": "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg==",
"license": "MIT",
"dependencies": {
"@react-navigation/routers": "^7.5.5",
@ -2098,12 +2098,12 @@
}
},
"node_modules/@react-navigation/native": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.4.tgz",
"integrity": "sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==",
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.5.tgz",
"integrity": "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg==",
"license": "MIT",
"dependencies": {
"@react-navigation/core": "^7.17.4",
"@react-navigation/core": "^7.17.5",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.3.11",
@ -5932,9 +5932,9 @@
}
},
"node_modules/react-native-app-auth": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-native-app-auth/-/react-native-app-auth-8.3.0.tgz",
"integrity": "sha512-GdoTfi8121KYH0+ALTNQxiECi0Qblrqx3fw74d3dwH5K+nT2GQJ4KZtmw7kkQm6pGp+cIf/JVu4ZhfZqQPyIUw==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/react-native-app-auth/-/react-native-app-auth-8.4.0.tgz",
"integrity": "sha512-9MCjB2tUyEUVIoqfgxMRoYx5dUgiI15bHEAWi4LgZWPe6S3cqaxqgQ/V22hxirTVIpyufag6WCHISTYGKlm9Og==",
"license": "MIT",
"dependencies": {
"invariant": "2.2.4",
@ -5983,9 +5983,9 @@
}
},
"node_modules/react-native-image-picker": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-7.2.3.tgz",
"integrity": "sha512-zKIZUlQNU3EtqizsXSH92zPeve4vpUrsqHu2kkpCxWE9TZhJFZBb+irDsBOY8J21k0+Edgt06TMQGJ+iPUIXyA==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/react-native-image-picker/-/react-native-image-picker-8.2.1.tgz",
"integrity": "sha512-FBeGYJGFDjMdGCcyubDJgBAPCQ4L1D3hwLXyUU91jY9ahOZMTbluceVvRmrEKqnDPFJ0gF1NVhJ0nr1nROFLdg==",
"license": "MIT",
"peerDependencies": {
"react": "*",
@ -6006,9 +6006,9 @@
}
},
"node_modules/react-native-paper": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.2.tgz",
"integrity": "sha512-e0HoKG+G85e5ckM28qElaqiDD+y0uHLVCUmOWe5094OuB9NDX4kKTY3twCVb2PPgA6lB0pQylu/O4Bvyg/gIYA==",
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.15.3.tgz",
"integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==",
"license": "MIT",
"workspaces": [
"example",
@ -6061,9 +6061,9 @@
}
},
"node_modules/react-native-screens": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.1.tgz",
"integrity": "sha512-9gAFwkzcvBrQHIQjIT+hz1gUows1hqCEkp40oj+Wh3jXo6aG8mysFom9++HDKSLaOk2rgSUTaUgKOTff9c2udQ==",
"version": "4.25.2",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.2.tgz",
"integrity": "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==",
"license": "MIT",
"dependencies": {
"react-freeze": "^1.0.0",

View file

@ -14,7 +14,7 @@
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-community/netinfo": "^12.0.1",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native": "^7.2.5",
"@react-navigation/native-stack": "^7.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
@ -22,14 +22,14 @@
"axios": "^1.16.0",
"react": "19.2.6",
"react-native": "^0.85.3",
"react-native-app-auth": "^8.3.0",
"react-native-app-auth": "^8.4.0",
"react-native-document-picker": "^9.3.0",
"react-native-haptic-feedback": "^2.3.0",
"react-native-image-picker": "^7.2.0",
"react-native-image-picker": "^8.2.1",
"react-native-keychain": "^9.2.0",
"react-native-paper": "^5.13.0",
"react-native-paper": "^5.15.3",
"react-native-safe-area-context": "^5.8.0",
"react-native-screens": "^4.5.0",
"react-native-screens": "^4.25.2",
"react-native-share": "^11.0.0",
"react-native-vector-icons": "^10.2.0",
"react-redux": "^9.2.0"

View file

@ -77,7 +77,7 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
);
}
})
.catch(() => {});
.catch((err) => console.error('Offline queue sync failed:', err));
}
});
return unsubscribe;

View file

@ -60,37 +60,52 @@ export async function queueOfflineSubmission(data: unknown): Promise<void> {
await AsyncStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue));
}
let _isProcessing = false;
export async function processOfflineQueue(
submitFn: (data: unknown) => Promise<unknown>,
): Promise<number> {
const state = await NetInfo.fetch();
if (!state.isConnected) return 0;
if (_isProcessing) return 0;
_isProcessing = true;
const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
if (!stored) return 0;
try {
const state = await NetInfo.fetch();
if (!state.isConnected) return 0;
const queue: QueuedSubmission[] = JSON.parse(stored);
let processed = 0;
const stored = await AsyncStorage.getItem(OFFLINE_QUEUE_KEY);
if (!stored) return 0;
for (const item of queue) {
try {
await submitFn(item.data);
processed++;
} catch {
break;
const queue: QueuedSubmission[] = JSON.parse(stored);
let processed = 0;
for (const item of queue) {
try {
await submitFn(item.data);
processed++;
} catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status;
if (status && status >= 400 && status < 500) {
console.error('Permanent failure for queued submission, skipping:', error);
processed++;
continue;
}
break;
}
}
}
if (processed === queue.length) {
await AsyncStorage.removeItem(OFFLINE_QUEUE_KEY);
} else if (processed > 0) {
await AsyncStorage.setItem(
OFFLINE_QUEUE_KEY,
JSON.stringify(queue.slice(processed)),
);
}
if (processed === queue.length) {
await AsyncStorage.removeItem(OFFLINE_QUEUE_KEY);
} else if (processed > 0) {
await AsyncStorage.setItem(
OFFLINE_QUEUE_KEY,
JSON.stringify(queue.slice(processed)),
);
}
return processed;
return processed;
} finally {
_isProcessing = false;
}
}
export async function getOfflineQueueCount(): Promise<number> {

View file

@ -36,43 +36,31 @@ function ProposalsNavigator() {
return (
<ProposalsStack.Navigator screenOptions={{ headerShown: true }}>
{isAdmin ? (
<>
<ProposalsStack.Screen
name="ProposalList"
component={ProposalQueueScreen}
options={{ title: 'Proposal Queue' }}
/>
<ProposalsStack.Screen
name="ProposalWorkspace"
component={ProposalWorkspaceScreen}
options={{ title: 'Proposal' }}
/>
<ProposalsStack.Screen
name="LineItemEdit"
component={LineItemEditScreen}
options={{ title: 'Edit Line Item' }}
/>
</>
) : (
<>
<ProposalsStack.Screen
name="ProposalList"
component={ProposalQueueScreen}
options={{ title: 'My Proposals' }}
/>
<ProposalsStack.Screen
name="NewProposal"
component={NewProposalScreen}
options={{ title: 'New Proposal' }}
/>
<ProposalsStack.Screen
name="ProposalDetail"
component={ProposalDetailScreen}
options={{ title: 'Proposal' }}
/>
</>
)}
<ProposalsStack.Screen
name="ProposalList"
component={ProposalQueueScreen}
options={{ title: isAdmin ? 'Proposal Queue' : 'My Proposals' }}
/>
<ProposalsStack.Screen
name="NewProposal"
component={NewProposalScreen}
options={{ title: 'New Proposal' }}
/>
<ProposalsStack.Screen
name="ProposalDetail"
component={ProposalDetailScreen}
options={{ title: 'Proposal' }}
/>
<ProposalsStack.Screen
name="ProposalWorkspace"
component={ProposalWorkspaceScreen}
options={{ title: 'Proposal' }}
/>
<ProposalsStack.Screen
name="LineItemEdit"
component={LineItemEditScreen}
options={{ title: 'Edit Line Item' }}
/>
</ProposalsStack.Navigator>
);
}

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Generate a proposal PDF locally using the Lambda's template.
Usage: python3 scripts/generate-pdf-local.py <proposal-id> <output-dir>
Fetches proposal data from the local API, generates a PDF, and saves it.
Prints JSON with the output path and s3Key on success.
"""
import json
import os
import sys
import httpx
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lambdas", "pdf-generate"))
from app import build_pdf # noqa: E402
API_URL = os.environ.get("API_URL", "http://localhost:5000")
def main():
if len(sys.argv) < 3:
print("Usage: generate-pdf-local.py <proposal-id> <output-dir>", file=sys.stderr)
sys.exit(1)
proposal_id = sys.argv[1]
output_dir = sys.argv[2]
token_resp = httpx.post(
f"{API_URL}/api/auth/dev-login",
json={"email": "adam@seahavenind.com", "role": "SysAdmin"},
timeout=10,
)
token_resp.raise_for_status()
token = token_resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
proposal_resp = httpx.get(
f"{API_URL}/api/proposals/{proposal_id}", headers=headers, timeout=10
)
proposal_resp.raise_for_status()
proposal = proposal_resp.json()
li_resp = httpx.get(
f"{API_URL}/api/proposals/{proposal_id}/line-items",
headers=headers,
timeout=10,
)
li_resp.raise_for_status()
line_items = li_resp.json()
pdf_bytes = build_pdf(proposal, line_items)
proposal_number = proposal["proposalNumber"]
revision = proposal.get("currentRevision", 1)
s3_key = f"{proposal_number}/rev-{revision}.pdf"
output_path = os.path.join(output_dir, s3_key)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "wb") as f:
f.write(pdf_bytes)
print(json.dumps({"path": output_path, "s3Key": s3_key, "size": len(pdf_bytes)}))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,131 @@
import puppeteer from 'puppeteer';
const BASE = 'http://localhost:5173';
const OUT = process.env.HOME + '/Desktop/proposal-system-screenshots';
const ROLES = [
{ label: 'SysAdmin', prefix: 'sysadmin', isAdmin: true, isSysAdmin: true },
{ label: 'Admin', prefix: 'admin', isAdmin: true, isSysAdmin: false },
{ label: 'Dispatcher', prefix: 'dispatcher', isAdmin: false, isSysAdmin: false },
];
async function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
async function screenshot(page, name) {
await wait(1500);
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
console.log(` captured ${name}.png`);
}
async function getToken(page) {
return page.evaluate(() => {
const raw = localStorage.getItem('proposal_system_token');
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed?.token ?? null;
} catch { return null; }
});
}
async function fetchProposalId(page) {
const token = await getToken(page);
if (!token) return null;
return page.evaluate(async (t) => {
try {
const r = await fetch('/api/proposals?page=1&pageSize=1', {
headers: { 'Authorization': `Bearer ${t}` },
});
if (!r.ok) return null;
const data = await r.json();
return data.items?.[0]?.id ?? null;
} catch { return null; }
}, token);
}
async function run() {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: { width: 1440, height: 900 },
});
for (const role of ROLES) {
console.log(`\n--- ${role.prefix} ---`);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Login page
await page.goto(BASE + '/login', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-01-login`);
// Click the role button
const buttons = await page.$$('button');
let clicked = false;
for (const b of buttons) {
const text = await b.evaluate(el => el.textContent.trim());
if (text.startsWith(role.label)) {
await b.click();
clicked = true;
break;
}
}
if (!clicked) {
console.log(` ERROR: could not find button "${role.label}"`);
await context.close();
continue;
}
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => {});
await wait(1000);
// Dashboard
await screenshot(page, `${role.prefix}-02-dashboard`);
// My Proposals list
await page.goto(BASE + '/proposals', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-03-proposals-list`);
// New Proposal form
await page.goto(BASE + '/proposals/new', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-04-new-proposal`);
// Detail page
const proposalId = await fetchProposalId(page);
if (proposalId) {
await page.goto(BASE + `/proposals/${proposalId}`, { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-05-proposal-detail`);
} else {
console.log(' no proposals found, skipping detail');
}
if (role.isAdmin) {
await page.goto(BASE + '/admin', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-06-admin-queue`);
await page.goto(BASE + '/admin/proposals', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-07-admin-all-proposals`);
const workspaceId = proposalId ?? await fetchProposalId(page);
if (workspaceId) {
await page.goto(BASE + `/admin/proposals/${workspaceId}`, { waitUntil: 'networkidle2' });
await wait(2000);
await screenshot(page, `${role.prefix}-08-admin-workspace`);
} else {
console.log(' no proposals for workspace');
}
if (role.isSysAdmin) {
await page.goto(BASE + '/admin/users', { waitUntil: 'networkidle2' });
await screenshot(page, `${role.prefix}-09-user-management`);
}
}
await context.close();
}
await browser.close();
console.log(`\nDone — screenshots in ${OUT}`);
}
run().catch(e => { console.error(e); process.exit(1); });

View file

@ -1,5 +1,6 @@
export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised';
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation';
// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other')
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other';
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical';
export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both';

View file

@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proposal System - Sea Haven Industries</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

1186
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@ -14,19 +16,24 @@
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.13",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.4",
"react-router-dom": "^7.16.0",
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^29.1.1",
"typescript": "~5.7.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.7"
}
}

View file

@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Box, Toolbar, Typography } from '@mui/material';
import { Box, Button, Card, CardContent, Toolbar, Typography } from '@mui/material';
import PeopleIcon from '@mui/icons-material/People';
import { selectSidebarOpen } from './app/slices/uiSlice';
import type { RootState } from './app/store';
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
@ -52,7 +53,30 @@ export default function App() {
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} />
<Route path="/admin/users" element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<Card sx={{ maxWidth: 520, p: 4, textAlign: 'center' }}>
<CardContent>
<PeopleIcon sx={{ fontSize: 48, color: '#94A3B8', mb: 2 }} />
<Typography variant="h5" sx={{ mb: 1 }}>User Management</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access.
</Typography>
<Button
variant="outlined"
size="large"
href="https://us-east-1.console.aws.amazon.com/cognito/v2/idp/user-pools?region=us-east-1"
target="_blank"
rel="noopener noreferrer"
>
Manage in Cognito
</Button>
</CardContent>
</Card>
</Box>
</RoleGuard>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View file

@ -0,0 +1,114 @@
/**
* QA-C5: Auth slice tests.
*
* Tests the Redux auth state management:
* - setUser stores user and marks authenticated
* - logout clears user and marks unauthenticated
* - Expired token sets isAuthenticated to false
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import authReducer, { setUser, logout, selectUser, selectIsAuthenticated } from '../slices/authSlice';
import type { AuthUser } from '../../lib/api/auth';
// Mock localStorage
const mockStorage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem: (key: string) => mockStorage[key] ?? null,
setItem: (key: string, value: string) => { mockStorage[key] = value; },
removeItem: (key: string) => { delete mockStorage[key]; },
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); },
});
function createValidToken(): string {
// Create a JWT-like token with exp far in the future
const header = btoa(JSON.stringify({ alg: 'HS256' }));
const payload = btoa(JSON.stringify({
sub: 'test-user',
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
}));
return `${header}.${payload}.fake-signature`;
}
function createExpiredToken(): string {
const header = btoa(JSON.stringify({ alg: 'HS256' }));
const payload = btoa(JSON.stringify({
sub: 'test-user',
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
}));
return `${header}.${payload}.fake-signature`;
}
describe('authSlice', () => {
beforeEach(() => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
});
it('QA-C5: setUser stores user and marks authenticated with valid token', () => {
const user: AuthUser = {
id: 'user-1',
email: 'admin@test.com',
displayName: 'Admin',
role: 'Admin',
token: createValidToken(),
};
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
const state = authReducer(initialState, setUser(user));
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
it('QA-C5: setUser with expired token sets isAuthenticated to false', () => {
const user: AuthUser = {
id: 'user-1',
email: 'admin@test.com',
displayName: 'Admin',
role: 'Admin',
token: createExpiredToken(),
};
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
const state = authReducer(initialState, setUser(user));
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(false);
});
it('QA-C5: logout clears user and isAuthenticated', () => {
const user: AuthUser = {
id: 'user-1',
email: 'admin@test.com',
displayName: 'Admin',
role: 'Admin',
token: createValidToken(),
};
const loggedInState = { user, isAuthenticated: true, loading: false, error: null };
const state = authReducer(loggedInState, logout());
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.error).toBeNull();
});
it('QA-C5: selectUser returns user from state', () => {
const user: AuthUser = {
id: 'user-1',
email: 'admin@test.com',
displayName: 'Admin',
role: 'Admin',
token: createValidToken(),
};
const state = { auth: { user, isAuthenticated: true, loading: false, error: null } };
expect(selectUser(state)).toEqual(user);
});
it('QA-C5: selectIsAuthenticated returns false when no user', () => {
const state = { auth: { user: null, isAuthenticated: false, loading: false, error: null } };
expect(selectIsAuthenticated(state)).toBe(false);
});
});

View file

@ -9,9 +9,12 @@ interface AuthState {
error: string | null;
}
// Fix: WEB-C1 — use sessionStorage instead of localStorage to limit JWT exposure.
// Tokens are cleared when the browser tab closes, reducing XSS token-theft window.
// Follow-up: move to httpOnly cookies once the API supports Set-Cookie auth flow.
const getUserFromStorage = (): AuthUser | null => {
try {
const result = window.localStorage.getItem(STORAGE_KEY_TOKEN);
const result = window.sessionStorage.getItem(STORAGE_KEY_TOKEN);
return result ? JSON.parse(result) : null;
} catch {
return null;
@ -48,13 +51,13 @@ const authSlice = createSlice({
state.isAuthenticated = action.payload.token ? isTokenValid(action.payload.token) : false;
state.loading = false;
state.error = null;
window.localStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload));
window.sessionStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload)); // WEB-C1
},
logout: (state) => {
state.user = null;
state.isAuthenticated = false;
state.error = null;
window.localStorage.removeItem(STORAGE_KEY_TOKEN);
window.sessionStorage.removeItem(STORAGE_KEY_TOKEN); // WEB-C1
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;

View file

@ -0,0 +1,41 @@
import React from 'react';
import { Box, Button, Typography } from '@mui/material';
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', gap: 2, p: 4 }}>
<Typography variant="h5">Something went wrong</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 480, textAlign: 'center' }}>
An unexpected error occurred. Please try refreshing the page.
</Typography>
<Button variant="contained" onClick={() => window.location.assign('/')}>
Return to Dashboard
</Button>
</Box>
);
}
return this.props.children;
}
}

View file

@ -1,8 +1,17 @@
import { Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import { useAuth } from '../hooks/useAuth';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;

View file

@ -1,5 +1,5 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import {
Drawer,
List,
@ -9,6 +9,9 @@ import {
Toolbar,
Box,
Typography,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DescriptionIcon from '@mui/icons-material/Description';
@ -16,11 +19,13 @@ import AddCircleIcon from '@mui/icons-material/AddCircle';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
import AssignmentIcon from '@mui/icons-material/Assignment';
import PeopleIcon from '@mui/icons-material/People';
import { selectSidebarOpen } from '../app/slices/uiSlice';
import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store';
import { DRAWER_WIDTH } from '../constants';
const COLLAPSED_WIDTH = 72;
const dispatcherNav = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },
{ label: 'My Proposals', path: '/proposals', icon: <DescriptionIcon fontSize="small" /> },
@ -50,102 +55,134 @@ function isNavActive(pathname: string, itemPath: string): boolean {
export default function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const theme = useTheme();
const open = useSelector((state: RootState) => selectSidebarOpen(state));
const user = useSelector((state: RootState) => selectUser(state));
const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px
const isCollapsed = useMediaQuery(theme.breakpoints.between('md', 'lg')); // 900–1200px
const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin';
const isSysAdmin = user?.role === 'SysAdmin';
const navItems = dispatcherNav;
const drawerVariant = isMobile ? 'temporary' : 'persistent';
const effectiveWidth = isCollapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH;
const handleNavClick = (path: string) => {
navigate(path);
if (isMobile) {
dispatch(setSidebarOpen(false));
}
};
const handleDrawerClose = () => {
dispatch(setSidebarOpen(false));
};
const renderNavItem = (item: { label: string; path: string; icon: React.ReactNode }) => {
const button = (
<ListItemButton
key={item.path}
selected={isNavActive(location.pathname, item.path)}
onClick={() => handleNavClick(item.path)}
sx={{
py: 1,
justifyContent: isCollapsed ? 'center' : 'flex-start',
minHeight: 40,
}}
>
<ListItemIcon
sx={{
minWidth: isCollapsed ? 0 : 40,
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
{!isCollapsed && (
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
)}
</ListItemButton>
);
if (isCollapsed) {
return (
<Tooltip key={item.path} title={item.label} placement="right" arrow>
{button}
</Tooltip>
);
}
return button;
};
return (
<Drawer
variant="persistent"
variant={drawerVariant}
open={open}
onClose={handleDrawerClose}
sx={{
width: open ? DRAWER_WIDTH : 0,
width: open ? effectiveWidth : 0,
flexShrink: 0,
transition: 'width 250ms ease',
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
width: effectiveWidth,
boxSizing: 'border-box',
overflowX: 'hidden',
},
}}
>
<Toolbar sx={{ minHeight: 64 }} />
<Box sx={{ overflow: 'auto', flexGrow: 1, pt: 1 }}>
<Typography
sx={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase',
color: '#9aa0a6',
px: 2.5,
mb: 0.5,
}}
>
Proposals
</Typography>
<List disablePadding>
{navItems.map((item) => (
<ListItemButton
key={item.path}
selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)}
sx={{ py: 1 }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton>
))}
</List>
{isAdmin && (
<>
<Typography
sx={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase',
color: '#9aa0a6',
px: 2.5,
mt: 2,
mb: 0.5,
}}
>
Admin
</Typography>
{!isCollapsed && (
<Typography
sx={{
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#94A3B8',
px: 2.5,
mb: 0.5,
}}
>
Admin
</Typography>
)}
<List disablePadding>
{adminNav.map((item) => (
<ListItemButton
key={item.path}
selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)}
sx={{ py: 1 }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton>
))}
{isSysAdmin && sysadminNav.map((item) => (
<ListItemButton
key={item.path}
selected={isNavActive(location.pathname, item.path)}
onClick={() => navigate(item.path)}
sx={{ py: 1 }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: '13px' }} />
</ListItemButton>
))}
{adminNav.map((item) => renderNavItem(item))}
{isSysAdmin && sysadminNav.map((item) => renderNavItem(item))}
</List>
</>
)}
{!isCollapsed && (
<Typography
sx={{
fontSize: '11px',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#94A3B8',
px: 2.5,
mt: isAdmin ? 2 : 0,
mb: 0.5,
}}
>
Proposals
</Typography>
)}
<List disablePadding>
{navItems.map((item) => renderNavItem(item))}
</List>
</Box>
{user && (
<Box sx={{ p: 2, mt: 'auto', borderTop: '1px solid #d9dde0' }}>
<Box sx={{ fontSize: '13px', fontWeight: 500 }}>{user.displayName}</Box>
<Box sx={{ fontSize: '11px', color: '#9aa0a6' }}>{user.role}</Box>
{user && !isCollapsed && (
<Box sx={{ p: 2, mt: 'auto', borderTop: '1px solid #DDE3E8' }}>
<Box sx={{ fontSize: '13px', fontWeight: 500, color: '#1F2933' }}>{user.displayName}</Box>
<Box sx={{ fontSize: '11px', color: '#8A949E' }}>{user.role}</Box>
</Box>
)}
</Drawer>

View file

@ -13,6 +13,8 @@ export default function Topbar() {
? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
: '';
const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN;
return (
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, height: 64 }}>
<Toolbar sx={{ height: 64, minHeight: 64 }}>
@ -27,28 +29,50 @@ export default function Topbar() {
</IconButton>
<Typography variant="h6" noWrap sx={{ flexGrow: 1, fontWeight: 600 }}>
Proposal System
{isDevMode && (
<Box
component="span"
sx={{
background: '#FEF3C7',
color: '#92400E',
border: '1px solid #FCD34D',
height: 22,
borderRadius: '999px',
fontSize: '11px',
fontWeight: 700,
pl: '8px',
pr: '8px',
display: 'inline-flex',
alignItems: 'center',
ml: '12px',
verticalAlign: 'middle',
}}
>
DEV
</Box>
)}
</Typography>
{user && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#1fa8de', fontSize: 12, fontWeight: 600 }}>
<Avatar sx={{ width: 32, height: 32, bgcolor: '#0E7490', fontSize: 12, fontWeight: 600 }}>
{initials}
</Avatar>
<Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: '#fff' }}>
{user.displayName}
</Typography>
<Typography sx={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}>
<Typography sx={{ fontSize: '11px', color: 'rgba(255,255,255,0.55)' }}>
{user.role}
</Typography>
</Box>
</Box>
<Box sx={{ width: '1px', height: 24, bgcolor: 'rgba(255,255,255,0.15)', mx: 0.5 }} />
<Box sx={{ width: '1px', height: 24, bgcolor: 'rgba(255,255,255,0.12)', mx: 0.5 }} />
<IconButton
onClick={logout}
size="small"
aria-label="Log out"
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
sx={{ color: 'rgba(255,255,255,0.55)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
>
<LogoutIcon fontSize="small" />
</IconButton>

View file

@ -0,0 +1,153 @@
/**
* QA-C5: ProtectedRoute and RoleGuard component tests.
*
* Tests that:
* - ProtectedRoute renders children when authenticated
* - ProtectedRoute redirects to /login when not authenticated
* - RoleGuard renders children when user has correct role
* - RoleGuard redirects to / when user has wrong role
* - RoleGuard redirects to / when user is null
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import ProtectedRoute, { RoleGuard } from '../ProtectedRoute';
// Mock the useAuth hook
const mockUseAuth = vi.fn();
vi.mock('../../hooks/useAuth', () => ({
useAuth: () => mockUseAuth(),
}));
function renderWithRouter(ui: React.ReactElement, initialRoute = '/protected') {
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<Routes>
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
<Route path="/" element={<div data-testid="home-page">Home Page</div>} />
<Route path="/protected" element={ui} />
</Routes>
</MemoryRouter>,
);
}
describe('ProtectedRoute', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('QA-C5: renders children when user is authenticated', () => {
mockUseAuth.mockReturnValue({ isAuthenticated: true, user: null, loading: false });
renderWithRouter(
<ProtectedRoute>
<div data-testid="protected-content">Protected Content</div>
</ProtectedRoute>,
);
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
});
it('QA-C5: redirects to /login when user is not authenticated', () => {
mockUseAuth.mockReturnValue({ isAuthenticated: false, user: null, loading: false });
renderWithRouter(
<ProtectedRoute>
<div data-testid="protected-content">Protected Content</div>
</ProtectedRoute>,
);
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
expect(screen.getByTestId('login-page')).toBeInTheDocument();
});
});
describe('RoleGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('QA-C5: renders children when user has an allowed role', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
user: { id: '1', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
loading: false,
});
renderWithRouter(
<RoleGuard roles={['Admin', 'SysAdmin']}>
<div data-testid="admin-content">Admin Content</div>
</RoleGuard>,
);
expect(screen.getByTestId('admin-content')).toBeInTheDocument();
});
it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' },
loading: false,
});
renderWithRouter(
<RoleGuard roles={['Admin', 'SysAdmin']}>
<div data-testid="admin-content">Admin Content</div>
</RoleGuard>,
);
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument();
expect(screen.getByTestId('home-page')).toBeInTheDocument();
});
it('QA-C5: redirects to / when user is null', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: false,
user: null,
loading: false,
});
renderWithRouter(
<RoleGuard roles={['Admin']}>
<div data-testid="admin-content">Admin Content</div>
</RoleGuard>,
);
expect(screen.queryByTestId('admin-content')).not.toBeInTheDocument();
expect(screen.getByTestId('home-page')).toBeInTheDocument();
});
it('QA-C5: SysAdmin can access sysadmin-only routes', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' },
loading: false,
});
renderWithRouter(
<RoleGuard roles={['SysAdmin']}>
<div data-testid="sysadmin-content">SysAdmin Content</div>
</RoleGuard>,
);
expect(screen.getByTestId('sysadmin-content')).toBeInTheDocument();
});
it('QA-C5: Admin cannot access sysadmin-only routes', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
user: { id: '4', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
loading: false,
});
renderWithRouter(
<RoleGuard roles={['SysAdmin']}>
<div data-testid="sysadmin-content">SysAdmin Content</div>
</RoleGuard>,
);
expect(screen.queryByTestId('sysadmin-content')).not.toBeInTheDocument();
expect(screen.getByTestId('home-page')).toBeInTheDocument();
});
});

View file

@ -15,6 +15,7 @@ import {
Tooltip,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
@ -88,6 +89,25 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
};
const duplicateItem = (index: number) => {
const source = items[index];
if (!source) return;
const newItem: EditableLineItem = {
id: null,
description: source.description,
quantity: source.quantity,
unit: source.unit,
unitPrice: source.unitPrice,
totalPrice: source.totalPrice,
pricingMode: source.pricingMode,
sortOrder: 0,
source: 'Manual',
};
const updated = [...items];
updated.splice(index + 1, 0, newItem);
onChange(updated.map((item, i) => ({ ...item, sortOrder: i + 1 })));
};
const moveItem = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= items.length) return;
@ -109,8 +129,8 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
Line Items ({items.length})
</Typography>
{!disabled && (
<Button size="small" startIcon={<AddIcon />} onClick={addItem}>
Add Row
<Button size="small" variant={items.length === 0 ? 'contained' : 'outlined'} startIcon={<AddIcon />} onClick={addItem}>
Add Line Item
</Button>
)}
</Box>
@ -126,7 +146,7 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell sx={{ width: 110 }}>Unit Price</TableCell>
<TableCell sx={{ width: 110 }}>Total</TableCell>
<TableCell sx={{ width: 90 }}>Source</TableCell>
{!disabled && <TableCell sx={{ width: 100 }}>Actions</TableCell>}
{!disabled && <TableCell sx={{ width: 120 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@ -217,6 +237,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
</TableCell>
{!disabled && (
<TableCell>
<Tooltip title="Duplicate">
<IconButton size="small" onClick={() => duplicateItem(index)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Remove">
<IconButton size="small" color="error" onClick={() => removeItem(index)}>
<DeleteIcon fontSize="small" />
@ -228,10 +253,16 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 3 }}>
<Typography color="text.secondary">
No line items yet. {!disabled && 'Click "Add Row" to start.'}
<TableCell colSpan={disabled ? 7 : 8} align="center" sx={{ py: 5 }}>
<Typography variant="subtitle2">No line items yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Add labor, material, equipment, or other cost items.
</Typography>
{!disabled && (
<Button variant="contained" startIcon={<AddIcon />} onClick={addItem}>
Add Line Item
</Button>
)}
</TableCell>
</TableRow>
)}
@ -241,7 +272,7 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
Proposal Total: {subtotal === 0 ? 'Pending pricing' : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
</Typography>
</Box>
</Box>

View file

@ -60,12 +60,6 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
Similar Proposals {similar && similar.length > 0 && `(${similar.length})`}
</Typography>
{(!similar || similar.length === 0) && (
<Typography variant="body2" color="text.secondary">
No similar proposals found. AI suggestions will populate this panel once the RAG engine is active.
</Typography>
)}
{similar?.map((sp, idx) => (
<Accordion key={idx} disableGutters sx={{ '&:before': { display: 'none' }, mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ minHeight: 'auto', px: 1 }}>

View file

@ -17,21 +17,47 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token';
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const;
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const;
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
export const STATUS_LABELS: Record<string, string> = {
Draft: 'Draft',
InReview: 'In Review',
Approved: 'Approved',
Sent: 'Sent',
Revised: 'Revised',
};
export const DRAWER_WIDTH = 220;
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
Standard: 'default',
Urgent: 'warning',
Emergency: 'error',
export const STATUS_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
Draft: { bgcolor: '#F1F5F9', color: '#334155', border: '1px solid #CBD5E1' },
InReview: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' },
Approved: { bgcolor: '#DCFCE7', color: '#166534', border: '1px solid #86EFAC' },
Sent: { bgcolor: '#DBEAFE', color: '#1E40AF', border: '1px solid #93C5FD' },
Revised: { bgcolor: '#F3E8FF', color: '#6B21A8', border: '1px solid #C084FC' },
Rejected: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #FCA5A5' },
};
export const DRAWER_WIDTH = 216;
export const PRIORITY_LABELS: Record<string, string> = {
Standard: 'Standard',
Urgent: 'Urgent',
Emergency: 'Emergency Dispatch',
};
export const PRIORITY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
Standard: { bgcolor: '#E2E8F0', color: '#334155', border: '1px solid #CBD5E1' },
Urgent: { bgcolor: '#FFEDD5', color: '#9A3412', border: '1px solid #FDBA74' },
Emergency: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #F87171' },
};
export const PRIORITY_ROW_SX: Record<string, object> = {
Urgent: {
backgroundColor: '#FFF7ED',
},
Emergency: {
borderLeft: '4px solid',
borderLeftColor: 'error.main',
backgroundColor: '#FEF2F2',
},
};

View file

@ -0,0 +1,277 @@
/**
* API client interceptor tests (WEB-M2, WEB-C1).
*
* Tests that:
* - Request interceptor attaches Bearer token from sessionStorage
* - Request interceptor handles missing/invalid token data gracefully
* - Response interceptor dispatches Redux logout and redirects on 401
* - Response interceptor returns friendly error message on 403
* - Response interceptor returns friendly error message on 404
* - Response interceptor extracts server error detail from response body
* - Response interceptor handles network errors (no response)
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import type { InternalAxiosRequestConfig } from 'axios';
// vi.hoisted returns values accessible in both the hoisted mock scope and test scope.
const { interceptors, mockDispatch } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state: Record<string, any> = {};
const dispatch = vi.fn();
return {
interceptors: state,
mockDispatch: dispatch,
};
});
// Mock the Redux store
vi.mock('../../../app/store', () => ({
store: {
dispatch: (...args: unknown[]) => mockDispatch(...args),
getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }),
subscribe: vi.fn(),
replaceReducer: vi.fn(),
[Symbol.observable]: vi.fn(),
},
}));
// Mock the authSlice logout action
vi.mock('../../../app/slices/authSlice', () => ({
logout: () => ({ type: 'auth/logout' }),
setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }),
default: (state: unknown) => state,
}));
// Mock axios
vi.mock('axios', () => {
const mockInstance = {
interceptors: {
request: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
use: (fulfilled: any, rejected: any) => {
interceptors.requestFulfilled = fulfilled;
interceptors.requestRejected = rejected;
return 0;
},
},
response: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
use: (fulfilled: any, rejected: any) => {
interceptors.responseFulfilled = fulfilled;
interceptors.responseRejected = rejected;
return 0;
},
},
},
defaults: { headers: { common: {} } },
};
return {
default: {
create: () => mockInstance,
},
};
});
// Mock sessionStorage
const sessionStorageData: Record<string, string> = {};
vi.stubGlobal('sessionStorage', {
getItem: vi.fn((key: string) => sessionStorageData[key] ?? null),
setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }),
removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }),
clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }),
get length() { return Object.keys(sessionStorageData).length; },
key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null),
});
// Mock window.location
const mockLocation = { href: '' };
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
configurable: true,
});
describe('API client interceptors', () => {
beforeAll(async () => {
// Dynamically import the module under test after all mocks are set up
await import('../client');
});
beforeEach(() => {
vi.clearAllMocks();
Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]);
mockLocation.href = '';
});
describe('request interceptor', () => {
it('WEB-C1: attaches Bearer token from sessionStorage when valid token data exists', () => {
const tokenData = JSON.stringify({ token: 'my-jwt-token-123' });
sessionStorageData['proposal_system_token'] = tokenData;
const config = {
headers: {} as Record<string, string>,
} as unknown as InternalAxiosRequestConfig;
const result = interceptors.requestFulfilled(config);
expect(result.headers.Authorization).toBe('Bearer my-jwt-token-123');
});
it('WEB-C1: does not attach Authorization header when no token in sessionStorage', () => {
const config = {
headers: {} as Record<string, string>,
} as unknown as InternalAxiosRequestConfig;
const result = interceptors.requestFulfilled(config);
expect(result.headers.Authorization).toBeUndefined();
});
it('WEB-C1: handles malformed JSON in sessionStorage gracefully', () => {
sessionStorageData['proposal_system_token'] = 'not-valid-json{{{';
const config = {
headers: {} as Record<string, string>,
} as unknown as InternalAxiosRequestConfig;
const result = interceptors.requestFulfilled(config);
expect(result.headers.Authorization).toBeUndefined();
});
it('WEB-C1: does not attach header when token field is missing in parsed data', () => {
sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' });
const config = {
headers: {} as Record<string, string>,
} as unknown as InternalAxiosRequestConfig;
const result = interceptors.requestFulfilled(config);
expect(result.headers.Authorization).toBeUndefined();
});
it('WEB-C1: request error rejection propagates the error', async () => {
const error = new Error('request setup failed');
const promise = interceptors.requestRejected(error);
await expect(promise).rejects.toEqual(error);
});
});
describe('response interceptor', () => {
it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => {
const error = {
response: {
status: 401,
data: {},
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('Session expired. Please log in again.');
expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' });
expect(mockLocation.href).toBe('/login');
});
it('WEB-M2: 403 response returns permission error without dispatching logout', async () => {
const error = {
response: {
status: 403,
data: {},
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('You do not have permission to perform this action.');
expect(mockDispatch).not.toHaveBeenCalled();
});
it('404 response returns resource-not-found error', async () => {
const error = {
response: {
status: 404,
data: {},
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('The requested resource was not found.');
});
it('extracts detail message from server error response', async () => {
const error = {
response: {
status: 422,
data: { detail: 'Validation failed: scope too short' },
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('Validation failed: scope too short');
});
it('extracts title message when detail is absent', async () => {
const error = {
response: {
status: 500,
data: { title: 'Internal Server Error' },
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('Internal Server Error');
});
it('falls back to generic message when no detail/title/message in response', async () => {
const error = {
response: {
status: 500,
data: {},
},
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('An error occurred');
});
it('handles network error (no response from server)', async () => {
const error = {
request: {},
};
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toThrow('No response from server. Please check your connection.');
});
it('passes through non-axios errors unchanged', async () => {
const error = new Error('Something unexpected');
const promise = interceptors.responseRejected(error);
await expect(promise).rejects.toEqual(error);
});
it('response success passes through unchanged', () => {
const response = { data: { id: 1 }, status: 200 };
const result = interceptors.responseFulfilled(response);
expect(result).toBe(response);
});
});
});

View file

@ -1,4 +1,5 @@
import apiClient from './client';
import { type ProposalDetail } from './proposals';
export interface DashboardStats {
pendingCount: number;
@ -10,6 +11,8 @@ export interface DashboardStats {
export interface UpdateProposalRequest {
refinedScope?: string;
notes?: string;
poNumber?: string;
workOrderNumber?: string;
assignedAdminId?: string;
}
@ -38,6 +41,10 @@ export const adminApi = {
await apiClient.post(`/proposals/${id}/approve`);
},
returnToReview: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/return-to-review`);
},
sendProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/send`);
},
@ -46,7 +53,7 @@ export const adminApi = {
await apiClient.post(`/proposals/${id}/revise`);
},
getHistory: async (id: string): Promise<unknown[]> => {
getHistory: async (id: string): Promise<ProposalDetail[]> => {
const res = await apiClient.get(`/proposals/${id}/history`);
return res.data;
},
@ -65,9 +72,15 @@ export const adminApi = {
return res.data;
},
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`);
getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => {
const params = regenerate ? '?regenerate=true' : '';
const res = await apiClient.get(`/proposals/${id}/pdf${params}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 202) return null;
return res.data;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};

View file

@ -1,5 +1,7 @@
import axios from 'axios';
import { API_URL, STORAGE_KEY_TOKEN } from '../../constants';
import { store } from '../../app/store';
import { logout } from '../../app/slices/authSlice';
const apiClient = axios.create({
baseURL: API_URL,
@ -11,7 +13,7 @@ const apiClient = axios.create({
apiClient.interceptors.request.use(
(config) => {
const tokenData = window.localStorage.getItem(STORAGE_KEY_TOKEN);
const tokenData = window.sessionStorage.getItem(STORAGE_KEY_TOKEN);
if (tokenData) {
try {
const parsed = JSON.parse(tokenData);
@ -34,7 +36,8 @@ apiClient.interceptors.response.use(
const { status, data } = error.response;
if (status === 401) {
window.localStorage.removeItem(STORAGE_KEY_TOKEN);
// Fix: WEB-M2 — dispatch Redux logout to clear auth state in addition to storage
store.dispatch(logout());
window.location.href = '/login';
return Promise.reject(new Error('Session expired. Please log in again.'));
}

View file

@ -1,12 +1,17 @@
import apiClient from './client';
// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts)
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other';
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
export interface CreateProposalRequest {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: string;
priority: string;
serviceCategory: ServiceCategory;
priority: Priority;
notes?: string;
}
@ -28,6 +33,7 @@ export interface ProposalDetail {
id: string;
proposalNumber: string;
workOrderNumber: string;
poNumber: string | null;
customerName: string;
customerAddress: string;
scopeOfWork: string;
@ -112,6 +118,33 @@ export const proposalsApi = {
const res = await apiClient.get('/proposals/stats');
return res.data;
},
getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
getPdfVersions: async (id: string): Promise<PdfVersion[]> => {
const res = await apiClient.get(`/proposals/${id}/pdf/versions`);
return res.data;
},
getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};
export interface ProposalStats {
@ -120,3 +153,8 @@ export interface ProposalStats {
approvedCount: number;
sentCount: number;
}
export interface PdfVersion {
revision: number;
generatedAt: string;
}

17
web/src/lib/api/sites.ts Normal file
View file

@ -0,0 +1,17 @@
import apiClient from './client';
export interface Site {
siteCode: string;
fullAddress: string | null;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
}
export const sitesApi = {
search: async (query: string): Promise<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};

Some files were not shown because too many files have changed in this diff Show more