# Phase 2 Part 2b (B2b) — Admin Controller + Auth Cleanup + Exception Handler + E2E Tests

**Status**: APPROVED (2026-04-14)
**Design question resolutions**: 1 (filename prefix — "admin-" chosen)
**Finding confirmations**: 3 (test infrastructure in scope, extensionFor duplication accepted, ReviewController cleanup added)

---

## 1. Executive Summary

B2b is the final deliverable of Phase 2, completing the admin manuscript access feature by exposing the HTTP API layer. It introduces one new file — `AdminPaperAccessController` with two GET endpoints for admin manuscript preview and download — and modifies four existing files: `GlobalExceptionHandler.java` (adding an `InvalidReasonException` handler), `SecurityConfig.java` (removing ADMIN from the `/reviewer/**` authorization rule), `ReviewerPaperController.java` (restricting the class-level `@PreAuthorize` to REVIEWER only), and `ReviewController.java` (same restriction). The headline deployment concern is **auth rule sequencing**: the new admin controller must be live before the auth cleanup deploys, otherwise admins lose all manuscript access during the deploy window. After B2b is deployed, Phase 2 is complete: admins have a dedicated, audited, fail-closed path to read paper manuscripts via `/admin/papers/{paperId}/manuscript[/download]`, the dual-gating technical debt from the reviewer feature is resolved, and the audit table captures every admin access with a mandatory reason. The primary risks are: (1) deploy ordering — the auth cleanup must come after the new controller; (2) fail-closed end-to-end propagation — the controller must not catch any service exceptions; (3) reviewer regression — the auth cleanup must not break existing reviewer access. B2b introduces 1 new file, 1 new test file, and modifies 4 existing files, for a total of 6 files touched and 2 new HTTP endpoints.

---

## 2. AdminPaperAccessController — HTTP Endpoint Layer

### 2.1 Reference Pattern to Match

The closest reference is `ReviewerPaperController` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/controller/api/ReviewerPaperController.java`. Its conventions:

- **Package**: `com.shodh.sanchayan.controller.api`
- **Class-level annotations**: `@RestController`, `@RequestMapping("/reviewer/papers")`, `@RequiredArgsConstructor`, `@PreAuthorize("hasAnyRole('REVIEWER','ADMIN')")`, `@Tag(name = "...", description = "...")`
- **Base path**: `/reviewer/papers`
- **Lombok annotations**: `@RequiredArgsConstructor` (no `@Slf4j` — the reviewer controller does not log)
- **`@PreAuthorize`**: class-level, applying to all endpoints
- **Binary response type**: `ResponseEntity<ByteArrayResource>` (lines 69, 89). The response body is constructed via `new ByteArrayResource(content.bytes())`
- **Content-Type header**: set via `.contentType(MediaType.parseMediaType(content.contentType()))` (line 79)
- **Content-Disposition header**: constructed via Spring's `ContentDisposition` builder — `ContentDisposition.inline().filename("...").build().toString()` for preview (line 81-83), `ContentDisposition.attachment().filename("...").build().toString()` for download (line 101-103)
- **Filename pattern**: `"review-" + content.referenceNo() + extensionFor(content.contentType())` — includes a prefix, the reference number, and a content-type-derived extension
- **HttpServletRequest injection**: as a method parameter (lines 71, 91)
- **Security context**: `SecurityUtils.getCurrentUserId()` for admin user UUID extraction (line 72), `SecurityUtils.getCurrentUserEmail()` for reviewer email (line 73 — not needed for admin)

However, the new admin controller belongs in the **admin controller package**, not the API package. The existing `AdminPaperController` is at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/controller/admin/AdminPaperController.java` in package `com.shodh.sanchayan.controller.admin`. Its conventions:

- **Package**: `com.shodh.sanchayan.controller.admin`
- **Class-level annotations**: `@RestController`, `@RequestMapping("/admin/papers")`, `@RequiredArgsConstructor`, `@PreAuthorize("hasRole('ADMIN')")`, `@Tag(name = "Admin — Papers", description = "...")`
- **Base path**: `/admin/papers`
- **`@PreAuthorize`**: `hasRole('ADMIN')` — single role, no SUPER_ADMIN (confirmed: `UserRole` enum at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/enums/UserRole.java` has only three values: `AUTHOR`, `REVIEWER`, `ADMIN`)

The new `AdminPaperAccessController` matches the admin controller conventions for package location and `@PreAuthorize`, and the reviewer controller conventions for binary response handling (since no existing admin controller returns binary content). Specific differences from the reviewer controller:

- **Different package** — `com.shodh.sanchayan.controller.admin` (admin package, not api package)
- **Different base path** — `/admin/papers` (matching the existing `AdminPaperController`'s base path)
- **Different security role** — `hasRole('ADMIN')` (matching `AdminPaperController`'s pattern, no SUPER_ADMIN)
- **Different service dependency** — injects `AdminPaperAccessService` (from B2a), not `ReviewerPaperService`
- **Mandatory query parameter** — `reason` is required on every endpoint; the reviewer endpoints have no equivalent
- **No agreement endpoints** — no POST endpoint for confidentiality agreement acceptance
- **Uses `HttpRequestUtils`** — the extracted utility from B2a, not inline helpers
- **Different filename prefix** — `"admin-"` prefix (see Section 2.3)

### 2.2 Controller Class — Package, File, Annotations

**File**: `AdminPaperAccessController.java` in package `com.shodh.sanchayan.controller.admin`.

**Class-level annotations:**

- `@RestController` — Spring REST controller
- `@RequestMapping("/admin/papers")` — same base path as the existing `AdminPaperController`. The new controller's endpoints are under `/{paperId}/manuscript` and `/{paperId}/manuscript/download`, which do not conflict with any existing `AdminPaperController` endpoints (those are `GET /`, `POST /{id}/assign`, `POST /{id}/publish`, `POST /{id}/request-revision`, `GET /{id}/assigned-reviewers`, `POST /{id}/reject`). The two controllers share the base path but own different sub-paths.
- `@PreAuthorize("hasRole('ADMIN')")` — class-level, matching `AdminPaperController`'s pattern. Every endpoint requires ADMIN role. No SUPER_ADMIN consideration needed because the role does not exist in the system.
- `@RequiredArgsConstructor` — constructor injection
- `@Tag(name = "Admin — Paper Manuscript Access", description = "Admin manuscript preview and download with audit trail")` — Swagger/OpenAPI tag, distinct from `AdminPaperController`'s tag to group the manuscript endpoints separately in API documentation

Decision on class-level vs method-level `@PreAuthorize`: class-level. Both endpoints require the same role. Method-level would be more verbose for no benefit. This matches `AdminPaperController`'s pattern exactly.

**Injected dependencies** (via `@RequiredArgsConstructor` with `private final` fields):

- `AdminPaperAccessService` — from B2a, the orchestration service

No other dependencies. The controller delegates all business logic, audit, and validation to the service. It does not inject `AdminPaperAccessAuditService` directly (that's the service's internal concern), nor `PaperRepository`, nor `StorageService`.

### 2.3 Endpoint 1 — Preview (GET /admin/papers/{paperId}/manuscript)

**Purpose**: Return the manuscript bytes for inline display in a browser tab or PDF viewer.

**HTTP method**: GET

**Path**: `/{paperId}/manuscript` (full path: `/admin/papers/{paperId}/manuscript`)

**Path variables:**

- `paperId` (UUID) — the paper to access

**Query parameters:**

- `reason` (String, required) — the admin's stated justification. Bound via Spring's `@RequestParam` annotation. When the parameter is missing from the request, Spring's binding framework automatically returns a 400 response with a standard missing-parameter error before the controller method is invoked. No explicit null-check code is needed in the controller for this case.

**Method parameters:**

- `HttpServletRequest` — for IP and user-agent extraction via `HttpRequestUtils`

**Reason validation at the controller layer:**

No controller-layer length validation. The service layer (B2a's `getManuscriptForAdmin`, Step A) already validates with the precise error codes (`REASON_REQUIRED`, `REASON_TOO_SHORT`). Adding controller-layer validation with Spring's `@Size` annotation would duplicate the logic and produce a different error format (Spring's standard `MethodArgumentNotValidException` format vs. the `InvalidReasonException` format with explicit error codes). A single validation source — the service layer — produces consistent error responses. The controller's job is to bind the request and delegate.

**Admin user extraction:**

The controller calls `SecurityUtils.getCurrentUserId()` to extract the admin's UUID from the Spring Security context. This matches the pattern used by `AdminPaperController` (line 47) and `ReviewerPaperController` (line 72). No `@AuthenticationPrincipal` annotation is used — the project uses the `SecurityUtils` static utility consistently.

**IP and user-agent extraction:**

The controller calls `HttpRequestUtils.clientIp(request)` and `HttpRequestUtils.userAgent(request)` — the extracted utility from B2a.

**Service call:**

The controller calls `AdminPaperAccessService.getManuscriptForAdmin` with the admin user UUID, paper UUID, `AdminPaperAccessType.PREVIEW`, the reason string, the IP, and the user-agent. The service returns a `ManuscriptContent` record or throws an exception.

**Response shape on success:**

`ResponseEntity<ByteArrayResource>` with:

- HTTP status 200 OK
- Content-Type header: `MediaType.parseMediaType(content.contentType())` — matching the reviewer controller's pattern
- Content-Disposition header: `ContentDisposition.inline().filename("admin-" + content.referenceNo() + extensionFor(content.contentType())).build().toString()` — inline display with filename using the `"admin-"` prefix plus the reference number and content-type-derived extension (e.g., `admin-SS-2026-00001.pdf`). The `"admin-"` prefix distinguishes admin downloads from reviewer downloads (`"review-"` prefix) in the user's Downloads folder. The `extensionFor` helper is a private static method on the controller, matching the reviewer controller's pattern (lines 154-163 of `ReviewerPaperController`). It maps content types to file extensions.
- Response body: `new ByteArrayResource(content.bytes())`

**Error responses:**

All exceptions propagate to the `GlobalExceptionHandler`. The controller does NOT catch any exceptions. The mapping:

- `InvalidReasonException` → B2b's new handler → 400 with code and message
- `ResourceNotFoundException` → existing handler (line 19-21) → 404
- `ManuscriptNotFoundException` → existing handler (line 67-71) → 404 with `MANUSCRIPT_NOT_FOUND`
- `DataAccessException` and similar from B1's audit service → existing `RuntimeException` catch-all (line 103-108) → 500 with generic message
- Any other exception → existing catch-all → 500

**Logging:**

The controller does not log. This matches the reviewer controller's pattern — `ReviewerPaperController` has no `@Slf4j` annotation and no log statements. Request-level logging is handled by Spring's infrastructure and the `GlobalExceptionHandler`'s ERROR log on exceptions. The service layer (B2a) logs INFO on successful access.

### 2.4 Endpoint 2 — Download (GET /admin/papers/{paperId}/manuscript/download)

**Purpose**: Return the manuscript bytes as a downloadable file.

**HTTP method**: GET

**Path**: `/{paperId}/manuscript/download` (full path: `/admin/papers/{paperId}/manuscript/download`)

Path variables, query parameters, method parameters, reason validation, admin user extraction, IP/user-agent extraction, service call: **identical to the preview endpoint**, except the access type is `AdminPaperAccessType.DOWNLOAD` instead of `PREVIEW`.

**Response shape on success:**

Identical to the preview endpoint, except:

- Content-Disposition is `attachment` instead of `inline`: `ContentDisposition.attachment().filename("admin-" + content.referenceNo() + extensionFor(content.contentType())).build().toString()` (e.g., `admin-SS-2026-00001.pdf`)
- The browser will trigger a file download instead of displaying inline

Error responses, logging: identical to the preview endpoint.

**Why two separate endpoints instead of one with a query parameter?**

Two endpoints separate the intent at the URL level. The reviewer feature already uses this two-endpoint pattern — `ReviewerPaperController` has separate `GET /{paperId}/manuscript` (line 67, preview) and `GET /{paperId}/manuscript/download` (line 87, download) with the same path structure. B2b matches this pattern exactly. This is clearer at the API documentation level, makes Spring Security configuration simpler (both endpoints under the same base path with the same role requirement), and keeps the admin and reviewer API surfaces parallel.

### 2.5 @PreAuthorize Placement and Coverage

Class-level `@PreAuthorize("hasRole('ADMIN')")`. Both endpoints inherit it.

The `UserRole` enum (`shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/enums/UserRole.java`) has three values: `AUTHOR`, `REVIEWER`, `ADMIN`. No `SUPER_ADMIN` role exists in the system. The existing `AdminPaperController` uses `hasRole('ADMIN')` (line 25). B2b matches this exactly. No SUPER_ADMIN consideration is needed.

### 2.6 The `extensionFor` Helper

The reviewer controller has a `private static String extensionFor(String contentType)` method (lines 154-163) that maps content types to file extensions. The admin controller needs the same helper for building the Content-Disposition filename.

This is a second copy of the helper. The current count is 1 (reviewer controller). Adding it to the admin controller makes 2 copies — below the count=3 refactor trigger. **Confirmed**: duplication at count=2 is accepted. If a third consumer appears in a later phase, extraction should occur then (candidate destination: `ContentTypeDetector` which already holds the detection logic). For now, the admin controller includes its own `private static extensionFor` method, copied verbatim from the reviewer controller.

### 2.7 Test Strategy for the Controller (Unit Level)

Unit test file location: `src/test/java/com/shodh/sanchayan/controller/admin/AdminPaperAccessControllerTest.java`, matching the controller's package. The existing test convention uses `@ExtendWith(MockitoExtension.class)` for unit tests (confirmed from `CmsContentServiceTest.java`).

Controller unit tests use `@WebMvcTest` or direct method invocation with mocked dependencies. Given the project's current test infrastructure (no existing `@WebMvcTest` tests), recommend direct method invocation with mocked `AdminPaperAccessService` and a mocked `HttpServletRequest`.

**Test cases:**

1. **Preview — service called with correct arguments**: Mock the service and request. Invoke the preview method. Verify the service receives the correct admin UUID, paper UUID, `PREVIEW` access type, reason, IP, and user-agent.

2. **Preview — response headers correct**: Mock the service to return a `ManuscriptContent` with PDF bytes and a reference number. Verify the response has `Content-Type: application/pdf` and `Content-Disposition: inline; filename="admin-SS-2026-00001.pdf"` (or whatever the reference number format produces).

3. **Download — Content-Disposition is attachment**: Same as above but for the download endpoint. Verify `Content-Disposition: attachment; filename="admin-SS-2026-00001.pdf"`.

4. **Both endpoints — IP/user-agent extraction**: Verify the controller calls `HttpRequestUtils.clientIp` and `HttpRequestUtils.userAgent` with the request object and passes the results to the service.

5. **Service exception propagation**: Mock the service to throw `InvalidReasonException`. Invoke the controller method. Verify the exception propagates out without being caught.

6. **Service exception propagation — audit failure**: Mock the service to throw `DataIntegrityViolationException`. Verify the exception propagates out without being caught.

---

## 3. Auth Rule Cleanup

### 3.1 Current State

From the discovery report and targeted reads:

- `SecurityConfig.java` line 58: `.requestMatchers("/reviewer/**").hasAnyRole("REVIEWER", "ADMIN")` — allows admin users to pass the HTTP security layer for all `/reviewer/**` endpoints.
- `ReviewerPaperController.java` line 59: `@PreAuthorize("hasAnyRole('REVIEWER','ADMIN')")` — allows admin users to pass the method-level security check.
- `ReviewController.java` line 26: `@PreAuthorize("hasAnyRole('REVIEWER', 'ADMIN')")` — same dual-gating problem on review submission/annotation endpoints.
- `ReviewAuthorizationServiceImpl.assertReviewerCanAccess()` — blocks admin users with a `ForbiddenException` because they have no active review assignment. The service checks `ACTIVE_STATUSES = List.of(PENDING, IN_PROGRESS, COMPLETED)` and finds no review for the admin user.

Result: admin users can pass the HTTP layer and the controller layer but always fail at the service layer with 403. This is technical debt from the reviewer paper access feature. The admin user goes through two unnecessary security checks that pass, hits a third that always fails, and gets a confusing 403 from the deepest layer instead of from the front door.

### 3.2 Target State

After B2b's auth cleanup:

- `SecurityConfig.java` line 58: `.requestMatchers("/reviewer/**").hasRole("REVIEWER")` — only REVIEWER role allowed
- `ReviewerPaperController.java` line 59: `@PreAuthorize("hasRole('REVIEWER')")` — only REVIEWER role allowed
- `ReviewController.java` line 26: `@PreAuthorize("hasRole('REVIEWER')")` — only REVIEWER role allowed
- Admin users hitting `/reviewer/**` get a 403 at the HTTP layer (clean, fast, consistent)
- Admin users have `/admin/papers/{paperId}/manuscript[/download]` for manuscript access (the new endpoints from Section 2)

**Confirmed**: `ReviewController.java` is included in the auth cleanup scope. It handles review submissions and annotations which are reviewer-only operations. An admin has no review assignment and would be blocked by the service layer regardless. Cleaning this up eliminates the same dual-gating problem on `ReviewController` that exists on `ReviewerPaperController`.

### 3.3 Deployment Sequencing — The Critical Decision

The auth rule cleanup must deploy AFTER the new admin controller is live. This is the most important sequencing requirement in Phase 2.

**Why this order matters:**

If the auth cleanup deploys first (before the new admin controller exists), there is a deploy window where:

- Admins cannot access `/reviewer/**` endpoints (blocked at HTTP layer by the updated rule)
- Admins cannot access `/admin/papers/{paperId}/manuscript` (the controller doesn't exist yet)
- Result: admins have ZERO manuscript access during the window

If the new admin controller deploys first (before the auth cleanup), there is a deploy window where:

- Admins can still technically reach `/reviewer/**` endpoints at the HTTP layer (the old rule still allows it), though the service layer blocks them with 403 as it always has
- Admins can also access the new `/admin/papers/{paperId}/manuscript` endpoints
- Result: admins have working manuscript access via the new endpoints, and the dual-gating problem persists harmlessly until the auth cleanup deploys

The second scenario is safe; the first scenario breaks admin functionality. Therefore: **deploy new admin controller first, auth cleanup second.**

**Implementation implication:**

B2b's deliverables must be sequenced into at least two commits or two PRs:

- **Commit/PR 1**: New admin controller, exception handler extension, and e2e tests for the new endpoints. After this lands, admins have working manuscript access via the new endpoints. The dual-gating on `/reviewer/**` still exists but is harmless.
- **Commit/PR 2**: Auth rule cleanup — modify `SecurityConfig.java`, `ReviewerPaperController.java`, and `ReviewController.java`. Includes the auth-related regression tests. After this lands, the dual-gating is resolved.

Recommend **two separate PRs**. Three reasons:

First, the two have different risk profiles. The new controller is additive (zero risk to existing functionality). The auth cleanup is a behavior change that affects existing reviewer endpoints (small risk of breaking a code path no one expected).

Second, two PRs allow independent rollback. If the auth cleanup causes an unexpected issue, reverting it does not also revert the new admin controller.

Third, two PRs allow the auth cleanup to be deployed at a chosen window (e.g., during low traffic) rather than being coupled to the new feature deployment.

**Sequencing within a single deployment cycle:**

If both PRs are deployed in the same application restart, Spring Boot starts all controllers and security filters together — there is no deploy ordering at the application level once both pieces of code are present. The "ordering" matters only between the two PR merges, not within a single application restart. Once both PRs are merged and the application restarts, the final state is correct regardless of in-process ordering. No startup ordering logic is needed.

### 3.4 Test Strategy for the Auth Cleanup

Two integration tests verify the cleanup (included in Section 5's enumeration, called out here for emphasis):

1. **Reviewer regression**: An authenticated reviewer with a valid review assignment can still access `GET /reviewer/papers/{paperId}/manuscript` and receives a 200 with bytes. This proves the auth cleanup did not break reviewer access.

2. **Admin blocked from reviewer endpoints**: An authenticated admin (without any review assignment) hits `GET /reviewer/papers/{paperId}/manuscript` and receives a 403 at the HTTP layer. Before the cleanup, the admin would pass the HTTP layer and get 403 from the service layer; after cleanup, the 403 comes from the HTTP layer (earlier, cleaner, cheaper).

### 3.5 Auth Cleanup Rollback Plan

Revert the commits that modified `SecurityConfig.java`, `ReviewerPaperController.java`, and `ReviewController.java`. Rebuild and redeploy. The dual-gating state is restored (admins can pass HTTP and controller layers but get 403 at the service layer). No data cleanup needed — the auth rule change has no stateful side effects.

---

## 4. GlobalExceptionHandler Extension

### 4.1 Reference Pattern

The existing `GlobalExceptionHandler` at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/exception/GlobalExceptionHandler.java` uses these conventions:

- **Class-level annotations**: `@RestControllerAdvice` and `@Slf4j`
- **Handler method structure**: each method is annotated with `@ExceptionHandler(ExceptionType.class)`, takes the exception as its parameter, and returns `ResponseEntity<Map<String, Object>>`
- **Error response format**: a `Map<String, Object>` built by private helper methods — either `errorBody(int status, String message)` returning `{status, message, timestamp}`, or `errorBody(int status, String code, String message)` returning `{status, code, message, timestamp}` (lines 110-120)
- **HTTP status**: set via `ResponseEntity.status(HttpStatus.XXX)`
- **Closest reference handler**: the `InvalidManuscriptException` handler at lines 90-94 — returns 400 with the exception's code and message via the three-argument `errorBody` helper

### 4.2 New @ExceptionHandler for InvalidReasonException

Add a new method to `GlobalExceptionHandler` that handles `InvalidReasonException`. The method:

- Is annotated with `@ExceptionHandler(InvalidReasonException.class)`
- Returns `ResponseEntity<Map<String, Object>>`, matching all existing handlers
- Sets HTTP status to `400 Bad Request` via `ResponseEntity.status(HttpStatus.BAD_REQUEST)`
- Populates the response body using the existing `errorBody(int status, String code, String message)` helper with the exception's `getCode()` (either `REASON_REQUIRED` or `REASON_TOO_SHORT`) and `getMessage()`
- Does NOT log — the failure is a client input error (400), not a server fault. The existing `InvalidManuscriptException` handler (lines 90-94) also does not log. Matching that pattern.

The new handler is structurally identical to the existing `InvalidManuscriptException` handler with the exception type swapped. It adds approximately 5 lines to `GlobalExceptionHandler`.

### 4.3 No New Handler for Audit Failures

B2b does NOT add a specific error code for audit write failures. The reasoning:

- Audit failure exceptions (`DataAccessException`, `DataIntegrityViolationException`, `TransactionSystemException`) are caught by the existing `RuntimeException` catch-all handler (lines 103-108)
- The catch-all produces a 500 with the message "An unexpected error occurred" and logs the full stack trace at ERROR level
- Adding a specific handler with an error code like `AUDIT_WRITE_FAILED` would expose internal compliance machinery to admin users ("Audit logging failed; access denied"), which is not desirable from a security perspective
- A generic 500 is the right user-facing behavior — the admin sees "something went wrong, try again" without learning about the audit layer
- The operations team gets the full diagnostic information via the catch-all's ERROR log with stack trace

### 4.4 Test Strategy for the Exception Handler Extension

The `InvalidReasonException` handler is verified by the integration tests in Section 5 (tests 7-10 exercise the controller, which triggers service-layer validation, which throws `InvalidReasonException`, which is caught by the handler, which produces the 400 response with code and message). No separate unit test for the handler in isolation is needed, given its trivial structure (identical to the existing `InvalidManuscriptException` handler).

---

## 5. End-to-End Integration Test Strategy

### 5.1 Test Infrastructure

The project's existing test infrastructure:

- **Test framework**: JUnit 5 with Mockito (via `spring-boot-starter-test`). Spring Security test support available (`spring-security-test` dependency in pom.xml).
- **Existing test files**: Two unit test files exist — `CmsContentServiceTest.java` (Mockito-based, `@ExtendWith(MockitoExtension.class)`) and `UpdateCmsContentRequestTest.java` (Jakarta Validation). Neither uses `@SpringBootTest` or `@WebMvcTest`.
- **Test database**: `testcontainers:postgresql` dependency is present in pom.xml. No test-specific application configuration files exist (`src/test/resources/` has no `application-test.yml` or similar).
- **No existing integration test infrastructure**: No `@SpringBootTest`-based tests exist. B2b's integration tests would be the first to use `@SpringBootTest` with Testcontainers PostgreSQL.

**Confirmed**: standing up `@SpringBootTest` with Testcontainers is included in B2b's implementation scope as a test infrastructure setup step before the test cases themselves. This is not a separate task — it is a necessary part of "write integration tests."

The infrastructure setup requires:

- A test configuration file (likely `src/test/resources/application-test.yml`) or a test base class with `@DynamicPropertySource` that provides the Testcontainers PostgreSQL connection URL, username, and password to Spring Boot's auto-configuration
- A pattern for creating test fixture data (admin users, papers with manuscripts) — likely direct entity construction and repository saves in `@BeforeEach` methods
- A pattern for authenticating test requests — likely Spring Security test support's `@WithMockUser(roles = "ADMIN")` for role-based tests, or programmatic JWT generation for tests that need a real `CustomUserPrincipal` in the security context (for `SecurityUtils.getCurrentUserId()` to work)

If the infrastructure proves too complex for B2b's timeline, the fallback is `@WebMvcTest` with mocked dependencies (simpler but less valuable for fail-closed verification). Flag this decision for the implementation prompt.

### 5.2 Test File Structure

One integration test class: `AdminPaperAccessIntegrationTest` in `src/test/java/com/shodh/sanchayan/controller/admin/`. This class contains all e2e tests for the new admin endpoints and the auth cleanup verification.

The test class uses `@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)` with Testcontainers PostgreSQL (or `@SpringBootTest` with `MockMvc` depending on the test harness setup). Spring Security test support provides `@WithMockUser` or programmatic authentication for role-based testing.

A separate test file for auth cleanup regression is not needed — the auth-related tests are included in the main integration test class with clear method naming.

### 5.3 Enumerated Test Cases

**Happy path tests:**

1. **Admin previews a paper successfully**: Create an admin user and a paper with a manuscript file in storage. Authenticate as the admin. `GET /admin/papers/{paperId}/manuscript?reason=Investigating plagiarism report for editorial review`. Assert: HTTP 200, Content-Type matches the manuscript type, Content-Disposition is `inline` with a filename containing `admin-` prefix and the reference number, response body contains the manuscript bytes.

2. **Admin downloads a paper successfully**: Same setup. `GET /admin/papers/{paperId}/manuscript/download?reason=Investigating plagiarism report for editorial review`. Assert: HTTP 200, Content-Type matches, Content-Disposition is `attachment` with a filename containing `admin-` prefix and the reference number, response body contains the manuscript bytes.

**Audit verification tests:**

3. **Audit row exists after successful preview**: Perform the happy-path preview. Query the `admin_paper_access_audit` table directly via a test repository or JDBC. Assert: exactly one row exists with matching `admin_user_id`, `paper_id`, `access_type` = PREVIEW, `reason` = the submitted reason, `ip_address` is non-null, and `accessed_at` is a recent timestamp.

4. **Audit row exists after successful download**: Same as above for the download endpoint. Assert: `access_type` = DOWNLOAD.

**Validation failure tests:**

5. **Missing reason parameter**: `GET /admin/papers/{paperId}/manuscript` (no `reason` query param). Assert: HTTP 400. Spring's binding framework returns a missing-parameter error before the controller method is invoked.

6. **Empty reason parameter**: `GET /admin/papers/{paperId}/manuscript?reason=`. Assert: HTTP 400. The empty string reaches the service, which throws `InvalidReasonException` with `REASON_TOO_SHORT` (empty string after trim has length 0, which is < 10).

7. **Short reason parameter**: `GET /admin/papers/{paperId}/manuscript?reason=TooShort`. Assert: HTTP 400 with code `REASON_TOO_SHORT`.

8. **Whitespace-only reason parameter**: `GET /admin/papers/{paperId}/manuscript?reason=%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20` (20 URL-encoded spaces). Assert: HTTP 400 with code `REASON_TOO_SHORT` (trim collapses to empty, length 0 < 10).

**Authorization tests:**

9. **Non-admin blocked from admin endpoints**: Authenticate as a reviewer. `GET /admin/papers/{paperId}/manuscript?reason=valid reason for testing`. Assert: HTTP 403. The request is blocked by the `SecurityConfig` rule (line 56: `/admin/**` requires `hasRole("ADMIN")`).

10. **Unauthenticated request blocked**: `GET /admin/papers/{paperId}/manuscript?reason=valid reason for testing` without any authentication. Assert: HTTP 401 (Spring Security's default for unauthenticated requests in a stateless JWT configuration is 401 Unauthorized, sent by the JWT filter or Spring's `AuthenticationEntryPoint`).

11. **Admin blocked from reviewer endpoints (after auth cleanup)**: Authenticate as an admin (no review assignment). `GET /reviewer/papers/{paperId}/manuscript`. Assert: HTTP 403 at the HTTP layer. This verifies the auth cleanup.

12. **Reviewer regression — reviewer can still access reviewer endpoints**: Authenticate as a reviewer with a valid review assignment on the paper. `GET /reviewer/papers/{paperId}/manuscript`. Assert: HTTP 200 with bytes. This verifies the auth cleanup did not break reviewer access.

**Not-found tests:**

13. **Paper not found**: `GET /admin/papers/{nonexistent-uuid}/manuscript?reason=valid reason for testing`. Assert: HTTP 404.

14. **Paper exists but no manuscript uploaded**: Create a paper without a manuscript file (`manuscriptKey` and `manuscriptPdfKey` both null). `GET /admin/papers/{paperId}/manuscript?reason=valid reason for testing`. Assert: HTTP 404 with code `MANUSCRIPT_NOT_FOUND`.

**Failure mode tests:**

15. **Audit write failure — fail-closed verification**: Use `@MockBean` to replace `AdminPaperAccessAuditService` in the test context with a mock that throws `DataIntegrityViolationException` from `logAccess`. Authenticate as admin. `GET /admin/papers/{paperId}/manuscript?reason=valid reason for testing`. Assert: HTTP 500 (from the `GlobalExceptionHandler`'s catch-all), no manuscript bytes in the response body. If `StorageService` is also mockable, verify it received zero invocations — the byte fetch should never have been attempted. This is the most important test in all of Phase 2 — it verifies the fail-closed property end-to-end.

**Important note on test design**: Triggering the audit failure via a short reason that violates the DB CHECK constraint does NOT work, because B2a's `getManuscriptForAdmin` validates the reason at Step A BEFORE calling the audit service. A short reason throws `InvalidReasonException` at the validation step and never reaches the audit call. The `@MockBean` approach is correct because it injects the failure at the layer where it would actually occur in a real outage.

16. **Storage failure with audit committed**: Use `@MockBean` to replace `StorageService` with a mock that throws `StorageException` from `download`. Leave `AdminPaperAccessAuditService` as the real bean. Authenticate as admin. `GET /admin/papers/{paperId}/manuscript?reason=valid reason for testing`. Assert: HTTP 404 with `MANUSCRIPT_NOT_FOUND`. Then query the `admin_paper_access_audit` table and verify an audit row WAS written. This verifies B2a's audit-first ordering: the audit row exists even though the storage fetch failed, because B1's `REQUIRES_NEW` transaction committed the audit before the storage fetch was attempted.

**Helper extraction regression tests:**

17. **Reviewer controller still captures IP correctly**: Authenticate as a reviewer with a valid review assignment. Hit `GET /reviewer/papers/{paperId}/manuscript`. Query the `paper_access_audit` table (reviewer audit). Verify the `ip_address` column matches the test request's IP.

18. **Reviewer controller still captures user-agent correctly**: Same as above. Verify the `user_agent` column matches the test request's User-Agent header.

### 5.4 Test Coverage Notes

- Tests 1-2 cover the happy path
- Tests 3-4 verify audit trail correctness
- Tests 5-8 cover validation failures
- Tests 9-12 cover authorization (tests 11-12 verify the auth cleanup)
- Tests 13-14 cover not-found cases
- Tests 15-16 cover failure modes (test 15 is the most critical — fail-closed e2e)
- Tests 17-18 cover regression for B2a's helper extraction

If `@SpringBootTest` with Testcontainers proves too complex to stand up for B2b (given no existing precedent in the project), an alternative is to use `@WebMvcTest` with `@MockBean` for all dependencies. This trades off real database verification (tests 3, 4, 15-audit-check, 16-audit-check, 17, 18 would need to be simplified or deferred) for a simpler test harness. The recommendation is to attempt the full `@SpringBootTest` approach first because tests 15 and 16 are the most important tests in Phase 2 and require real transactional behavior to be meaningful.

---

## 6. Findings Discovered During B2b Specification

**Finding 1 — Missing integration test infrastructure (CONFIRMED: in scope):**

The project has no existing `@SpringBootTest`-based integration tests. The Testcontainers PostgreSQL dependency is present in pom.xml, but no test configuration exists (`src/test/resources/` is empty — no `application-test.yml` or `application-test.properties`). Standing up the integration test harness is included in B2b's implementation scope as a prerequisite step, not a separate task. See Section 5.1 for details.

**Finding 2 — `extensionFor` helper duplication (CONFIRMED: accepted at count=2):**

The admin controller needs the `extensionFor(String contentType)` helper that maps content types to file extensions. This helper currently exists only in `ReviewerPaperController` (lines 154-163). B2b will create a second copy in the admin controller. This is count=2, below the count=3 refactor trigger. Extraction deferred until a third consumer appears.

**Finding 3 — `ReviewController` auth cleanup (CONFIRMED: included):**

`ReviewController.java` (line 26) also has `@PreAuthorize("hasAnyRole('REVIEWER', 'ADMIN')")`. This is included in the auth cleanup PR alongside `ReviewerPaperController` and `SecurityConfig`. This adds `ReviewController.java` to B2b's modified files count (total: 4 modified files).

---

## 7. Design Question Resolution

**Question 1 — Filename pattern for Content-Disposition:**

**Resolved**: `"admin-" + referenceNo + extension` (e.g., `admin-SS-2026-00001.pdf`). The `"admin-"` prefix distinguishes admin downloads from reviewer downloads (`"review-"` prefix) in the admin's Downloads folder. This applies to both preview and download Content-Disposition filenames. The same prefix pattern is used by the reviewer controller (`"review-"`) — B2b parallels it.

**Question 2 — Integration test harness approach:**

**Resolved**: Approach **(A)** — full `@SpringBootTest` with Testcontainers. This is the recommended approach because tests 15 and 16 (fail-closed and audit-first verification) are the core compliance guarantees of Phase 2 and deserve real-database verification. If the implementation prompt finds that standing up Testcontainers is unexpectedly complex, it can fall back to **(B)** (`@WebMvcTest` with `@MockBean`) with a note that real-database tests are deferred to a later infrastructure task.

---

## 8. B2b Rollback Plan

### 8.1 New Controller Rollback

Revert the commit that introduced `AdminPaperAccessController`. The two new admin endpoints disappear. If the auth cleanup has already been deployed, admins now have zero manuscript access — the auth cleanup must also be rolled back to restore the dual-gating state. In practice: **roll back both the controller and the auth cleanup together** if the controller needs to come out after the auth cleanup has already landed.

### 8.2 Auth Cleanup Rollback

Revert the commits that modified `SecurityConfig.java`, `ReviewerPaperController.java`, and `ReviewController.java`. The dual-gating state is restored. Reviewer endpoints once again allow admin users to pass HTTP and controller layers (even though the service layer continues to block them with 403). No data cleanup needed.

### 8.3 Exception Handler Rollback

Revert the commit that added the `InvalidReasonException` handler. After revert, `InvalidReasonException` is caught by the existing `RuntimeException` catch-all and returns a generic 500 instead of a specific 400. This degrades error response quality but does not break functionality.

### 8.4 Data Loss Consideration

B2b adds no database tables, writes no rows of its own, and has no stateful changes beyond code deployment. Integration tests write rows to `admin_paper_access_audit` via the full stack, but those are test data isolated by the test database. Rollback has zero production data loss risk.

---

## 9. Implementation Sequence Within B2b

Recommended order:

1. **Integration test infrastructure setup** — Create the test configuration for `@SpringBootTest` with Testcontainers PostgreSQL. This is a prerequisite for all integration tests. Can be done first because it touches only test files.

2. **Exception handler extension** — Add the `InvalidReasonException` handler to `GlobalExceptionHandler`. No dependencies on other B2b deliverables. Can be implemented and tested in isolation.

3. **AdminPaperAccessController** — New controller. Depends on B2a's `AdminPaperAccessService`, `AdminPaperAccessType`, `HttpRequestUtils`. Unit-tested with mocked service.

4. **Integration tests for new endpoints** — Tests 1-10, 13-16 from Section 5.3. Depend on the controller, exception handler, and test infrastructure.

5. **Auth rule cleanup (separate PR)** — Modify `SecurityConfig.java`, `ReviewerPaperController.java`, and `ReviewController.java`. Include auth-related integration tests (tests 11-12) and helper regression tests (tests 17-18). This lands after step 4 is deployed to production.

Steps 1-4 are in a single PR. Step 5 is a separate PR. This matches the deployment sequencing requirement from Section 3.3.

---

## 10. Summary of B2b Deliverables

**New files (2):**

- `AdminPaperAccessController.java` — REST controller in `com.shodh.sanchayan.controller.admin` with two GET endpoints for admin manuscript preview and download
- `AdminPaperAccessIntegrationTest.java` — Integration test class in `src/test/java/com/shodh/sanchayan/controller/admin/` (plus any test infrastructure files for `@SpringBootTest` setup)

**Modified files (4):**

- `GlobalExceptionHandler.java` — add `InvalidReasonException` handler mapping to HTTP 400
- `SecurityConfig.java` — change `/reviewer/**` rule from `hasAnyRole("REVIEWER", "ADMIN")` to `hasRole("REVIEWER")`
- `ReviewerPaperController.java` — change class-level `@PreAuthorize` from `hasAnyRole('REVIEWER','ADMIN')` to `hasRole('REVIEWER')`
- `ReviewController.java` — change class-level `@PreAuthorize` from `hasAnyRole('REVIEWER', 'ADMIN')` to `hasRole('REVIEWER')`

**New tables:** 0

**New endpoints:** 2 (`GET /admin/papers/{paperId}/manuscript`, `GET /admin/papers/{paperId}/manuscript/download`)

**Total files touched:** 6 (2 new + 4 modified), plus test infrastructure files

**Phase 2 completion check:** After B2b is implemented and deployed, Phase 2 is complete. The admin manuscript access feature is live: admins have a dedicated, audited, fail-closed path to read paper manuscripts, the dual-gating technical debt from the reviewer feature is resolved, and the `admin_paper_access_audit` table captures every admin access with a mandatory reason. The three-layer reason validation (controller binding → service validation → DB CHECK constraint) provides defense in depth. The fail-closed discipline ensures no admin access occurs without an audit record.

**Explicitly out of scope:**

- Phase 3 design (reassignment workflow) — separate spec prompts
- Phase 4 design (admin override/annotation) — separate spec prompts
- Any frontend work — deferred to a later phase
- Any new business logic — B2b is the HTTP and security layer on top of B2a's already-designed service
