diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35dd55e..3274758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + env: JAVA_VERSION: '21' NODE_VERSION: '22' @@ -178,3 +181,61 @@ jobs: Library-Management-System-Version-2/backend.log retention-days: 7 if-no-files-found: ignore + + # -------------------------------------------------------------------------------------------- + # Publishes the built frontend to GitHub Pages. + # + # Pages serves static files and nothing else, so the API has to live somewhere else. Set the + # repository variable API_BASE_URL to a reachable backend origin and the site will talk to it; + # leave it unset and the app still loads, but every call fails and the UI says it cannot reach + # the library. That is the honest outcome for a static host with no server behind it. + # -------------------------------------------------------------------------------------------- + pages: + name: Deploy to GitHub Pages + # Only from the default branch, and only once the code is known to be good. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: [java, frontend] + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + + - uses: actions/configure-pages@v6 + + - name: Install + working-directory: frontend + run: npm ci + + - name: Build + working-directory: frontend + env: + # The site is served from https://.github.io//, so assets need that prefix. + VITE_BASE_PATH: /${{ github.event.repository.name }}/ + VITE_API_BASE_URL: ${{ vars.API_BASE_URL }} + run: npm run build + + - name: Add an SPA fallback + working-directory: frontend + # Pages has no rewrite rule, so a deep link like /books is a 404 until the same document is + # served for it. Copying index.html to 404.html is the usual way to let the router take over. + run: cp dist/index.html dist/404.html + + - uses: actions/upload-pages-artifact@v5 + with: + path: frontend/dist + + - name: Deploy + id: deployment + uses: actions/deploy-pages@v5 diff --git a/Library-Management-System-Version-2/src/main/java/app/adapters/input/rest/TransactionController.java b/Library-Management-System-Version-2/src/main/java/app/adapters/input/rest/TransactionController.java index 67033bf..7b2de24 100644 --- a/Library-Management-System-Version-2/src/main/java/app/adapters/input/rest/TransactionController.java +++ b/Library-Management-System-Version-2/src/main/java/app/adapters/input/rest/TransactionController.java @@ -93,12 +93,21 @@ public ResponseEntity returnBook(@PathVariable UUID bookId, } } + /** Only the member themselves or an administrator may borrow against a membership. */ @PostMapping(value = "/borrowBook/{customerId}/{bookId}", produces = {"application/transaction-response+json;version=1", MediaType.APPLICATION_JSON_VALUE}) @Operation(summary = "Borrow a book") public ResponseEntity borrowBook( @PathVariable UUID customerId, - @PathVariable UUID bookId) { + @PathVariable UUID bookId, + Authentication authentication) { + // The customer id comes from the path, so without this a member could borrow against + // somebody else's membership and spend their loan limit. Matches returnBook and extendLoan. + if (!isAdmin(authentication) && !isOwner(authentication, customerId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("You can only borrow against your own membership."); + } + try { transactionUseCase.borrowBook(customerId, bookId); return ResponseEntity.ok("Book borrowed successfully."); diff --git a/Library-Management-System-Version-2/src/test/java/app/adapters/input/TransactionControllerTestIT.java b/Library-Management-System-Version-2/src/test/java/app/adapters/input/TransactionControllerTestIT.java index b6e7a6d..9361087 100644 --- a/Library-Management-System-Version-2/src/test/java/app/adapters/input/TransactionControllerTestIT.java +++ b/Library-Management-System-Version-2/src/test/java/app/adapters/input/TransactionControllerTestIT.java @@ -125,6 +125,7 @@ void testCreateNewTransaction_BadRequest() throws Exception { .andExpect(status().isBadRequest()); } @Test + @WithMockUser(username = "member") void testBorrowBook() throws Exception { UUID customerId = customer.getCustomerId(); UUID bookId = book.getBookId(); @@ -137,6 +138,7 @@ void testBorrowBook() throws Exception { assertEquals(1, transactionCount); } @Test + @WithMockUser(username = "member") void testBorrowBook_bookNotAvailable() throws Exception { UUID customerId = customer.getCustomerId(); UUID bookId = book.getBookId(); @@ -292,4 +294,16 @@ public void tearDown() { bookRepository.deleteAll(); authorRepository.deleteAll(); } + + /** A member borrowing against somebody else's membership would spend their loan limit. */ + @Test + @WithMockUser(username = "member", roles = "USER") + void borrowingAgainstAnotherMembershipIsRefused() throws Exception { + Customer otherMember = customerUseCase.createNewCustomer( + new CreateNewCustomer("Someone Else", "someone.else@example.com", true)); + + mockMvc.perform(post("/transactions/borrowBook/" + otherMember.getCustomerId() + "/" + + book.getBookId())) + .andExpect(status().isForbidden()); + } } \ No newline at end of file diff --git a/README.md b/README.md index 359b652..6fb5318 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,30 @@ matrix runs with `fail-fast: false` so one red service does not hide the state o Test reports are published to the run summary, and on failure the surefire/failsafe reports, PMD and Checkstyle XML, and the Playwright trace are uploaded as artifacts. +## Deployment + +Pushes to `main` publish the built frontend to **GitHub Pages** at +`https://.github.io//`, after the Java and frontend jobs pass. Enable it once under +**Settings → Pages → Source → GitHub Actions**. + +Pages serves static files and nothing else, so **the API has to live somewhere else**: + +| Repository variable | Effect | +| ------------------- | ------ | +| `API_BASE_URL` unset | The site loads, every call fails, and the UI says it cannot reach the library | +| `API_BASE_URL` set to a backend origin | The site talks to that backend | + +Set it under **Settings → Secrets and variables → Actions → Variables**. A cross-origin backend +also needs a `CorsConfigurationSource` bean on the Spring side — the dev proxy that makes requests +same-origin locally does not exist on a static host. + +Two build-time details the deployment depends on: + +- **`VITE_BASE_PATH`** is set to `//`, because Pages serves from a subdirectory and absolute + asset paths would otherwise 404. +- **`404.html`** is a copy of `index.html`. Pages has no rewrite rule, so a deep link such as + `/books` is a 404 until the same document is served for it and the router can take over. + ## Code style One Checkstyle ruleset and one PMD ruleset in `config/`, shared by all three services so they are diff --git a/frontend/README.md b/frontend/README.md index 08a529b..76ebc24 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -138,6 +138,24 @@ src/ domain.ts types mirroring the backend JSON ``` +## Deploying to a static host + +`npm run build` produces a `dist/` that any static host can serve, but two things have to be set at +build time: + +```bash +VITE_BASE_PATH=/my-repo/ # when served from a subdirectory +VITE_API_BASE_URL=https://api.example.com # no dev proxy exists outside `npm run dev` +``` + +`VITE_API_BASE_URL` defaults to `/backend`, the dev proxy path. Point it at a real backend origin +and add CORS on the Spring side, since the requests are then cross-origin. Left unset on a static +host, the app loads and every call fails — which the UI reports as "can't reach the library" rather +than breaking. + +For an SPA, also copy `index.html` to `404.html`: a static host has no rewrite rule, so a deep link +is a 404 until the same document is served for it. + ## Error messages Nothing in the UI shows an HTTP status code. `Request failed with status 502` is a fact about diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 83b55c3..6803921 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,11 +1,13 @@ import type { Page, Session } from '../types/domain' /** - * All requests go through the Vite dev proxy (see vite.config.ts) so the browser - * treats them as same-origin. In production, point this at the real backend origin - * and add CORS on the Spring side. + * Where the API lives. + * + *

Locally this is the Vite dev proxy (see vite.config.ts), which keeps requests same-origin. + * A static deployment has no proxy, so set VITE_API_BASE_URL to the backend's own origin at build + * time - and add CORS on the Spring side, because the requests are then cross-origin. */ -const BASE = '/backend' +const BASE = import.meta.env.VITE_API_BASE_URL ?? '/backend' const TOKEN_KEY = 'library.jwt' const SESSION_KEY = 'library.session' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b2dc5b9..ad70ef0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,9 @@ import react from '@vitejs/plugin-react' // See src/api/client.ts, which prefixes all calls with /backend. // noinspection JSUnusedGlobalSymbols -- Loaded by name by Vite, never imported, so an IDE sees an export nobody uses. export default defineConfig({ + // '/' locally, '//' on GitHub Pages, where the site is served from a subdirectory and + // absolute asset paths would otherwise 404. + base: process.env.VITE_BASE_PATH ?? '/', plugins: [react()], server: { port: 5173,